Skip to content

Commit

Permalink
Update Settings: Update ITR settings item (#5398)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1207908166761516/1208966262488273/f

### Description
- Refactored subscription status management by introducing a generic
`ProductSubscriptionManager` to handle status checks for multiple
products
- Updated ITR settings UI to show different states (active, expired,
activating) with appropriate icons and click behaviors
- Added legacy support for ITR settings view to maintain backward
compatibility

### Steps to test this PR

Prerequisite: Enable `newSettings` feature toggle

Prerequisite: Apply the patch in the [Asana
task](https://app.asana.com/0/1207908166761516/1208966262488273/f)

Note: All PPro items should be seen in this PR, that is VPN, PIR, and
ITR.

_ITR Subscribed_
- [ ] In `RealSubscriptions` change ln 77 to `return flowOf(listOf(NetP,
PIR, ITR))`
- [ ] Open New Settings
- [ ] Verify ITR item is visible with colored icon and status is on
- [ ] Click ITR item
- [ ] ITR screen should open though the webpage will not load properly
as a result of the hacked setup for subscriptions. I have confirmed it
works with a real subscription

_Unsubscribed_
- [ ] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.UNKNOWN`
- [ ] Open New Settings
- [ ] Verify ITR item is not visible

_ITR Expired_
- [ ] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.EXPIRED`
- [ ] Open New Settings
- [ ] Verify ITR item is visible with grey icon and is not clickable,
and status is off
- [ ] Click ITR item
- [ ] Nothing should happen

_PPro Activating_
- [ ] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.WAITING`
- [ ] Open New Settings
- [ ] Verify ITR item is visible with grey icon and status is off
- [ ] Click ITR item
- [ ] Nothing should happen

_No Entitlement_
- [ ] In `SubscriptionManager` change ln 442 to `return
SubscriptionStatus.AUTO_RENEWABLE`
- [ ] In `RealSubscriptions` change ln 77 to `return flowOf(listOf())` 
- [ ] Open New Settings
- [ ] Verify ITR item is not visible

_Legacy Support_
- [ ] Turn off `newSettings`
- [ ] Verify old ITR settings works by repeating steps above

### UI changes
| Before | After |
| --- | --- |
|
![old_itr_subscribed](https://github.com/user-attachments/assets/9f6e39ab-d1c5-4a8b-971b-09ce5259818c)
|
![new_itr_renewable](https://github.com/user-attachments/assets/aae0533f-7082-4976-98d8-1dc08d0d28c5)
|
|
![old_itr_expired](https://github.com/user-attachments/assets/fd9e9c63-69f4-432a-b1e2-67420eac517a)
|
![new_itr_expired](https://github.com/user-attachments/assets/9a305360-8ad4-4b63-8eb6-5e1e5d977cb8)
|
|
![old_itr_waiting](https://github.com/user-attachments/assets/1bab7540-b03f-40db-8219-df11b8c2ced5)
|
![new_itr_activating](https://github.com/user-attachments/assets/8ead32db-560b-4195-b60d-b63cba1e3b3b)
|
|
![old_itr_ineligible](https://github.com/user-attachments/assets/0eb9be9e-7fd2-4836-83a3-d9a1acce2933)
|
![new_itr_ineligible](https://github.com/user-attachments/assets/dcbb96ed-acc2-4c7c-9337-4450ccf7c546)
|
|
![old_itr_unsubscribed](https://github.com/user-attachments/assets/6badeae4-3d4f-4de2-b60f-a27001b48951)
|
![new_itr_unsubscribed](https://github.com/user-attachments/assets/a42c2601-e2ed-4333-ba91-650a96c2d5c5)
|
  • Loading branch information
mikescamell authored Dec 19, 2024
1 parent 4b15b55 commit 8095023
Show file tree
Hide file tree
Showing 13 changed files with 607 additions and 223 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,23 @@
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl.pir
package com.duckduckgo.subscriptions.impl

import com.duckduckgo.di.scopes.AppScope
import com.duckduckgo.subscriptions.api.Product.PIR
import com.duckduckgo.subscriptions.api.Product
import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.api.Subscriptions
import com.duckduckgo.subscriptions.impl.pir.PirSubscriptionManager.PirStatus
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager.ProductStatus
import com.squareup.anvil.annotations.ContributesBinding
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

interface PirSubscriptionManager {
fun pirStatus(): Flow<PirStatus>
interface ProductSubscriptionManager {

enum class PirStatus {
fun entitlementStatus(vararg products: Product): Flow<ProductStatus>

enum class ProductStatus {
ACTIVE,
EXPIRED,
SIGNED_OUT,
Expand All @@ -40,21 +41,23 @@ interface PirSubscriptionManager {
}

@ContributesBinding(AppScope::class)
class RealPirSubscriptionManager @Inject constructor(
class RealProductSubscriptionManager @Inject constructor(
private val subscriptions: Subscriptions,
) : PirSubscriptionManager {
) : ProductSubscriptionManager {

override fun pirStatus(): Flow<PirStatus> = hasPirEntitlement().map { getPirStatusInternal(it) }
override fun entitlementStatus(vararg products: Product): Flow<ProductStatus> =
hasEntitlement(*products).map { getEntitlementStatusInternal(it) }

private fun hasPirEntitlement(): Flow<Boolean> = subscriptions.getEntitlementStatus().map { it.contains(PIR) }
private fun hasEntitlement(vararg products: Product): Flow<Boolean> =
subscriptions.getEntitlementStatus().map { entitledProducts -> entitledProducts.any { products.contains(it) } }

private suspend fun getPirStatusInternal(hasValidEntitlement: Boolean): PirStatus = when {
!hasValidEntitlement -> PirStatus.INELIGIBLE
private suspend fun getEntitlementStatusInternal(hasValidEntitlement: Boolean): ProductStatus = when {
!hasValidEntitlement -> ProductStatus.INELIGIBLE
else -> when (subscriptions.getSubscriptionStatus()) {
SubscriptionStatus.INACTIVE, SubscriptionStatus.EXPIRED -> PirStatus.EXPIRED
SubscriptionStatus.UNKNOWN -> PirStatus.SIGNED_OUT
SubscriptionStatus.AUTO_RENEWABLE, SubscriptionStatus.NOT_AUTO_RENEWABLE, SubscriptionStatus.GRACE_PERIOD -> PirStatus.ACTIVE
SubscriptionStatus.WAITING -> PirStatus.WAITING
SubscriptionStatus.INACTIVE, SubscriptionStatus.EXPIRED -> ProductStatus.EXPIRED
SubscriptionStatus.UNKNOWN -> ProductStatus.SIGNED_OUT
SubscriptionStatus.AUTO_RENEWABLE, SubscriptionStatus.NOT_AUTO_RENEWABLE, SubscriptionStatus.GRACE_PERIOD -> ProductStatus.ACTIVE
SubscriptionStatus.WAITING -> ProductStatus.WAITING
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.duckduckgo.settings.api.NewSettingsFeature
import com.duckduckgo.settings.api.ProSettingsPlugin
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingView
import com.duckduckgo.subscriptions.impl.settings.views.LegacyItrSettingView
import com.duckduckgo.subscriptions.impl.settings.views.LegacyPirSettingView
import com.duckduckgo.subscriptions.impl.settings.views.LegacyProSettingView
import com.duckduckgo.subscriptions.impl.settings.views.PirSettingView
Expand Down Expand Up @@ -68,8 +69,12 @@ class PIRSettings @Inject constructor(private val newSettingsFeature: NewSetting

@ContributesMultibinding(scope = ActivityScope::class)
@PriorityKey(400)
class ITRSettings @Inject constructor() : ProSettingsPlugin {
class ITRSettings @Inject constructor(private val newSettingsFeature: NewSettingsFeature) : ProSettingsPlugin {
override fun getView(context: Context): View {
return ItrSettingView(context)
return if (newSettingsFeature.self().isEnabled()) {
ItrSettingView(context)
} else {
LegacyItrSettingView(context)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 DuckDuckGo
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -20,22 +20,24 @@ import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.ViewViewModelFactory
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.subscriptions.impl.R
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants
import com.duckduckgo.subscriptions.impl.databinding.ViewItrSettingsBinding
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.Command
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.Command.OpenItr
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.ViewState
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.ViewState.ItrState
import com.duckduckgo.subscriptions.impl.ui.SubscriptionsWebViewActivityWithParams
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
Expand Down Expand Up @@ -75,10 +77,6 @@ class ItrSettingView @JvmOverloads constructor(

findViewTreeLifecycleOwner()?.lifecycle?.addObserver(viewModel)

binding.itrSettings.setClickListener {
viewModel.onItr()
}

@SuppressLint("NoHardcodedCoroutineDispatcher")
coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

Expand All @@ -100,10 +98,23 @@ class ItrSettingView @JvmOverloads constructor(
}

private fun renderView(viewState: ViewState) {
if (viewState.hasSubscription) {
binding.itrSettings.show()
} else {
binding.itrSettings.gone()
with(binding.itrSettings) {
when (viewState.itrState) {
is ItrState.Subscribed -> {
isVisible = true
setStatus(isOn = true)
setLeadingIconResource(R.drawable.ic_identity_theft_restoration_color_24)
isClickable = true
setClickListener { viewModel.onItr() }
}
ItrState.Expired, ItrState.Activating -> {
isVisible = true
isClickable = false
setStatus(isOn = false)
setLeadingIconResource(R.drawable.ic_identity_theft_restoration_grayscale_color_24)
}
ItrState.Hidden -> isGone = true
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 DuckDuckGo
* Copyright (c) 2024 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -25,9 +25,16 @@ import com.duckduckgo.anvil.annotations.ContributesViewModel
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.subscriptions.api.Product.ITR
import com.duckduckgo.subscriptions.api.Product.ROW_ITR
import com.duckduckgo.subscriptions.api.Subscriptions
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager.ProductStatus.ACTIVE
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager.ProductStatus.EXPIRED
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager.ProductStatus.INACTIVE
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager.ProductStatus.INELIGIBLE
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager.ProductStatus.SIGNED_OUT
import com.duckduckgo.subscriptions.impl.ProductSubscriptionManager.ProductStatus.WAITING
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.Command.OpenItr
import com.duckduckgo.subscriptions.impl.settings.views.ItrSettingViewModel.ViewState.ItrState
import javax.inject.Inject
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
Expand All @@ -37,12 +44,13 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

@SuppressLint("NoLifecycleObserver") // we don't observe app lifecycle
@ContributesViewModel(ViewScope::class)
class ItrSettingViewModel @Inject constructor(
private val subscriptions: Subscriptions,
private val productSubscriptionManager: ProductSubscriptionManager,
private val pixelSender: SubscriptionPixelSender,
) : ViewModel(), DefaultLifecycleObserver {

Expand All @@ -52,7 +60,16 @@ class ItrSettingViewModel @Inject constructor(

private val command = Channel<Command>(1, BufferOverflow.DROP_OLDEST)
internal fun commands(): Flow<Command> = command.receiveAsFlow()
data class ViewState(val hasSubscription: Boolean = false)
data class ViewState(val itrState: ItrState = ItrState.Hidden) {

sealed class ItrState {

data object Hidden : ItrState()
data object Subscribed : ItrState()
data object Expired : ItrState()
data object Activating : ItrState()
}
}

private val _viewState = MutableStateFlow(ViewState())
val viewState = _viewState.asStateFlow()
Expand All @@ -64,8 +81,16 @@ class ItrSettingViewModel @Inject constructor(

override fun onCreate(owner: LifecycleOwner) {
super.onCreate(owner)
subscriptions.getEntitlementStatus().onEach {
_viewState.emit(viewState.value.copy(hasSubscription = ITR in it || ROW_ITR in it))

productSubscriptionManager.entitlementStatus(ITR, ROW_ITR).onEach { status ->
val itrState = when (status) {
ACTIVE -> ItrState.Subscribed
INACTIVE, EXPIRED -> ItrState.Expired
WAITING -> ItrState.Activating
SIGNED_OUT, INELIGIBLE -> ItrState.Hidden
}

_viewState.update { it.copy(itrState = itrState) }
}.launchIn(viewModelScope)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
* Copyright (c) 2023 DuckDuckGo
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.duckduckgo.subscriptions.impl.settings.views

import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.findViewTreeViewModelStoreOwner
import com.duckduckgo.anvil.annotations.InjectWith
import com.duckduckgo.common.ui.view.gone
import com.duckduckgo.common.ui.view.show
import com.duckduckgo.common.ui.viewbinding.viewBinding
import com.duckduckgo.common.utils.ConflatedJob
import com.duckduckgo.common.utils.ViewViewModelFactory
import com.duckduckgo.di.scopes.ViewScope
import com.duckduckgo.navigation.api.GlobalActivityStarter
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants
import com.duckduckgo.subscriptions.impl.databinding.LegacyViewItrSettingsBinding
import com.duckduckgo.subscriptions.impl.settings.views.LegacyItrSettingViewModel.Command
import com.duckduckgo.subscriptions.impl.settings.views.LegacyItrSettingViewModel.Command.OpenItr
import com.duckduckgo.subscriptions.impl.settings.views.LegacyItrSettingViewModel.ViewState
import com.duckduckgo.subscriptions.impl.ui.SubscriptionsWebViewActivityWithParams
import dagger.android.support.AndroidSupportInjection
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

@InjectWith(ViewScope::class)
class LegacyItrSettingView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0,
) : FrameLayout(context, attrs, defStyle) {

@Inject
lateinit var viewModelFactory: ViewViewModelFactory

@Inject
lateinit var globalActivityStarter: GlobalActivityStarter

private var coroutineScope: CoroutineScope? = null

private val binding: LegacyViewItrSettingsBinding by viewBinding()

private val viewModel: LegacyItrSettingViewModel by lazy {
ViewModelProvider(findViewTreeViewModelStoreOwner()!!, viewModelFactory)[LegacyItrSettingViewModel::class.java]
}

private var job: ConflatedJob = ConflatedJob()

override fun onAttachedToWindow() {
AndroidSupportInjection.inject(this)
super.onAttachedToWindow()

findViewTreeLifecycleOwner()?.lifecycle?.addObserver(viewModel)

binding.itrSettings.setClickListener {
viewModel.onItr()
}

@SuppressLint("NoHardcodedCoroutineDispatcher")
coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

job += viewModel.commands()
.onEach { processCommands(it) }
.launchIn(coroutineScope!!)

viewModel.viewState
.onEach { renderView(it) }
.launchIn(coroutineScope!!)
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
findViewTreeLifecycleOwner()?.lifecycle?.removeObserver(viewModel)
coroutineScope?.cancel()
job.cancel()
coroutineScope = null
}

private fun renderView(viewState: ViewState) {
if (viewState.hasSubscription) {
binding.itrSettings.show()
} else {
binding.itrSettings.gone()
}
}

private fun processCommands(command: Command) {
when (command) {
is OpenItr -> {
globalActivityStarter.start(
context,
SubscriptionsWebViewActivityWithParams(
url = SubscriptionsConstants.ITR_URL,
),
)
}
}
}
}
Loading

0 comments on commit 8095023

Please sign in to comment.