diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-06-09 16:43:14 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2025-06-09 16:43:14 +0200 |
| commit | 8b0b5ab45c3e0720797bd381d4b02e70cf4043f9 (patch) | |
| tree | 4d5d5fc018053cf664be5c41040f8755de07c55d /android/app | |
| parent | 87e716c551f563b6bf181bcef87a58bee0fb2599 (diff) | |
| parent | 1c58ad3fc58c1862526d912efc311e06956317fd (diff) | |
| download | mullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.tar.xz mullvadvpn-8b0b5ab45c3e0720797bd381d4b02e70cf4043f9.zip | |
Merge branch 'implement-payment-screen-with-3-months-droid-1947'
Diffstat (limited to 'android/app')
39 files changed, 2226 insertions, 1904 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheetTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheetTest.kt new file mode 100644 index 0000000000..095c15dafc --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheetTest.kt @@ -0,0 +1,372 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.Density +import de.mannodermaus.junit5.compose.ComposeContext +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.Unit +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.lib.payment.ProductIds +import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice +import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@OptIn(ExperimentalMaterial3Api::class) +class AddTimeBottomSheetTest { + @OptIn(ExperimentalTestApi::class) + @JvmField + @RegisterExtension + val composeExtension = createEdgeToEdgeComposeExtension() + + private fun ComposeContext.initBottomSheet( + state: Lc<Unit, AddTimeUiState> = Lc.Loading(Unit), + sheetState: SheetState = + SheetState( + skipPartiallyExpanded = true, + density = Density(1f), + initialValue = SheetValue.Expanded, + ), + onPurchaseBillingProductClick: (ProductId) -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, + onSitePaymentClick: () -> Unit = {}, + onRedeemVoucherClick: () -> Unit = {}, + onRetryFetchProducts: () -> Unit = {}, + resetPurchaseState: () -> Unit = {}, + closeSheetAndResetPurchaseState: (Boolean) -> Unit = {}, + closeBottomSheet: (animate: Boolean) -> Unit = {}, + ) { + setContentWithTheme { + AddTimeBottomSheetContent( + state = state, + sheetState = sheetState, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = onSitePaymentClick, + onRedeemVoucherClick = onRedeemVoucherClick, + onRetryFetchProducts = onRetryFetchProducts, + resetPurchaseState = resetPurchaseState, + closeSheetAndResetPurchaseState = closeSheetAndResetPurchaseState, + closeBottomSheet = closeBottomSheet, + ) + } + } + + @Test + fun testBuyCreditClick() = + composeExtension.use { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + showSitePayment = true, + tunnelStateBlocked = false, + ) + .toLc(), + onSitePaymentClick = mockedClickHandler, + ) + + // Act + onNodeWithText(BUY_CREDIT_TEXT).performClick() + + // Assert + verify(exactly = 1) { mockedClickHandler.invoke() } + } + + @Test + fun testRedeemVoucherClick() = + composeExtension.use { + // Arrange + val mockedClickHandler: () -> Unit = mockk(relaxed = true) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = true, + ) + .toLc(), + onRedeemVoucherClick = mockedClickHandler, + ) + + // Act + onNodeWithText("Redeem voucher").performClick() + + // Assert + verify { mockedClickHandler.invoke() } + } + + @Test + fun testShowBillingErrorPaymentButton() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = PaymentState.Error.Generic, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Failed to load products, please try again").assertExists() + } + + @Test + fun testShowBillingPaymentAvailable() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns null + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Add 30 days time ($10)").assertExists() + } + + @Test + fun testShowPendingPayment() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns PaymentStatus.PENDING + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Google Play payment pending, this might take some time").assertExists() + } + + @Test + fun testShowPendingPaymentInfoDialog() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns PaymentStatus.PENDING + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true) + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc(), + onPlayPaymentInfoClick = mockNavigateToVerificationPending, + ) + + // Act + onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick() + + // Assert + verify(exactly = 1) { mockNavigateToVerificationPending.invoke() } + } + + @Test + fun testShowVerificationInProgress() = + composeExtension.use { + // Arrange + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS + every { mockPaymentProduct.productId } returns ProductId(ProductIds.ThreeMonths) + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Verifying purchase").assertExists() + } + + @Test + fun testOnPurchaseBillingProductClick() = + composeExtension.use { + // Arrange + val clickHandler: (ProductId) -> Unit = mockk(relaxed = true) + val mockPaymentProduct: PaymentProduct = mockk() + every { mockPaymentProduct.price } returns ProductPrice("$10") + every { mockPaymentProduct.productId } returns ProductId(ProductIds.OneMonth) + every { mockPaymentProduct.status } returns null + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = + PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc(), + onPurchaseBillingProductClick = clickHandler, + ) + + // Act + onNodeWithText("Add 30 days time ($10)").performClick() + + // Assert + verify { clickHandler.invoke(ProductId(ProductIds.OneMonth)) } + } + + @Test + fun testShowPurchaseCompleteDialog() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = + PurchaseState.Success(ProductId(ProductIds.ThreeMonths)), + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Time added").assertExists() + onNodeWithText("90 days was added to your account.").assertExists() + } + + @Test + fun testShowVerificationErrorDialog() = + composeExtension.use { + // Arrange + initBottomSheet( + AddTimeUiState( + purchaseState = PurchaseState.VerifyingPurchase, + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText("Verifying purchase").assertExists() + } + + @Test + fun testShowFetchProductsErrorDialog() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = PurchaseState.Error.OtherError(ProductId("ProductId")), + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText( + "We were unable to start the payment process, please make sure you have the latest version of Google Play." + ) + .assertExists() + } + + @Test + fun testDisableSitePayment() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + tunnelStateBlocked = false, + showSitePayment = false, + ) + .toLc() + ) + + // Assert + onNodeWithText(BUY_CREDIT_TEXT).assertDoesNotExist() + } + + @Test + fun testShowInternetBlocked() = + composeExtension.use { + // Arrange + initBottomSheet( + state = + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + tunnelStateBlocked = true, + showSitePayment = true, + ) + .toLc() + ) + + // Assert + onNodeWithText("The app is blocking internet, please disconnect first").assertExists() + } + + companion object { + private const val BUY_CREDIT_TEXT = "Buy credit" + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt deleted file mode 100644 index 09a5e9dd72..0000000000 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/dialog/PaymentDialogTest.kt +++ /dev/null @@ -1,72 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog - -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.onNodeWithText -import de.mannodermaus.junit5.compose.ComposeContext -import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialog -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData -import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult -import net.mullvad.mullvadvpn.util.toPaymentDialogData -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.RegisterExtension - -class PaymentDialogTest { - @OptIn(ExperimentalTestApi::class) - @JvmField - @RegisterExtension - val composeExtension = createEdgeToEdgeComposeExtension() - - private fun ComposeContext.initDialog( - paymentDialogData: PaymentDialogData, - retryPurchase: (ProductId) -> Unit = {}, - onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit = {}, - ) { - setContentWithTheme { - PaymentDialog( - paymentDialogData = paymentDialogData, - retryPurchase = retryPurchase, - onCloseDialog = onCloseDialog, - ) - } - } - - @Test - fun testShowPurchaseCompleteDialog() = - composeExtension.use { - // Arrange - initDialog(paymentDialogData = PurchaseResult.Completed.Success.toPaymentDialogData()!!) - - // Assert - onNodeWithText("Time was successfully added").assertExists() - } - - @Test - fun testShowVerificationErrorDialog() = - composeExtension.use { - // Arrange - initDialog( - paymentDialogData = - PurchaseResult.Error.VerificationError(null).toPaymentDialogData()!! - ) - - // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testShowFetchProductsErrorDialog() = - composeExtension.use { - // Arrange - initDialog( - paymentDialogData = - PurchaseResult.Error.FetchProductsError(ProductId(""), null) - .toPaymentDialogData()!! - ) - - // Assert - onNodeWithText("Google Play unavailable").assertExists() - } -} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt index cc8c8e9943..9794fefa1e 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt @@ -2,7 +2,6 @@ package net.mullvad.mullvadvpn.compose.screen import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import de.mannodermaus.junit5.compose.ComposeContext @@ -10,38 +9,42 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState import net.mullvad.mullvadvpn.lib.model.AccountNumber -import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice -import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc import net.mullvad.mullvadvpn.viewmodel.AccountUiState +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @ExperimentalTestApi @OptIn(ExperimentalMaterial3Api::class) class AccountScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val addTimeViewModel: AddTimeViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { addTimeViewModel } }) + every { addTimeViewModel.uiState } returns + MutableStateFlow<Lc<Unit, AddTimeUiState>>(Lc.Loading(Unit)) } private fun ComposeContext.initScreen( - state: AccountUiState = AccountUiState.default(), + state: AccountUiState? = null, onCopyAccountNumber: (String) -> Unit = {}, onRedeemVoucherClick: () -> Unit = {}, - onManageAccountClick: () -> Unit = {}, onLogoutClick: () -> Unit = {}, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit = {}, - navigateToVerificationPendingDialog: () -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onManageDevicesClick: () -> Unit = {}, ) { @@ -49,13 +52,11 @@ class AccountScreenTest { AccountScreen( state = state, onCopyAccountNumber = onCopyAccountNumber, - onRedeemVoucherClick = onRedeemVoucherClick, - onManageAccountClick = onManageAccountClick, + onManageDevicesClick = onManageDevicesClick, onLogoutClick = onLogoutClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, onBackClick = onBackClick, - onManageDevicesClick = onManageDevicesClick, ) } } @@ -70,44 +71,17 @@ class AccountScreenTest { deviceName = DUMMY_DEVICE_NAME, accountNumber = DUMMY_ACCOUNT_NUMBER, accountExpiry = null, - showSitePayment = false, showLogoutLoading = false, - showManageAccountLoading = false, + verificationPending = false, ) ) // Assert - onNodeWithText("Redeem voucher").assertExists() onNodeWithText("Log out").assertExists() } @Test - fun testManageAccountClick() = - composeExtension.use { - // Arrange - val mockedClickHandler: () -> Unit = mockk(relaxed = true) - initScreen( - state = - AccountUiState( - deviceName = DUMMY_DEVICE_NAME, - accountNumber = DUMMY_ACCOUNT_NUMBER, - accountExpiry = null, - showSitePayment = true, - showLogoutLoading = false, - showManageAccountLoading = false, - ), - onManageAccountClick = mockedClickHandler, - ) - - // Act - onNodeWithText("Manage account").performClick() - - // Assert - verify(exactly = 1) { mockedClickHandler.invoke() } - } - - @Test - fun testRedeemVoucherClick() = + fun testLogoutClick() = composeExtension.use { // Arrange val mockedClickHandler: () -> Unit = mockk(relaxed = true) @@ -117,169 +91,38 @@ class AccountScreenTest { deviceName = DUMMY_DEVICE_NAME, accountNumber = DUMMY_ACCOUNT_NUMBER, accountExpiry = null, - showSitePayment = false, showLogoutLoading = false, - showManageAccountLoading = false, + verificationPending = false, ), - onRedeemVoucherClick = mockedClickHandler, + onLogoutClick = mockedClickHandler, ) // Act - onNodeWithText("Redeem voucher").performClick() + onNodeWithText("Log out").performClick() // Assert verify { mockedClickHandler.invoke() } } @Test - fun testLogoutClick() = + fun testShowVerificationInProgress() = composeExtension.use { // Arrange - val mockedClickHandler: () -> Unit = mockk(relaxed = true) initScreen( state = AccountUiState( deviceName = DUMMY_DEVICE_NAME, accountNumber = DUMMY_ACCOUNT_NUMBER, accountExpiry = null, - showSitePayment = false, showLogoutLoading = false, - showManageAccountLoading = false, - ), - onLogoutClick = mockedClickHandler, - ) - - // Act - onNodeWithText("Log out").performClick() - - // Assert - verify { mockedClickHandler.invoke() } - } - - @Test - fun testShowBillingErrorPaymentButton() = - composeExtension.use { - // Arrange - initScreen( - state = - AccountUiState.default().copy(billingPaymentState = PaymentState.Error.Billing) - ) - - // Assert - onNodeWithText("Add 30 days time").assertExists() - } - - @Test - fun testShowBillingPaymentAvailable() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns null - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) - ) - - // Assert - onNodeWithText("Add 30 days time ($10)").assertExists() - } - - @Test - fun testShowPendingPayment() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) + verificationPending = true, + ) ) // Assert onNodeWithText("Google Play payment pending").assertExists() } - @Test - fun testShowPendingPaymentInfoDialog() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true) - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - navigateToVerificationPendingDialog = mockNavigateToVerificationPending, - ) - - // Act - onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).performClick() - - // Assert - verify(exactly = 1) { mockNavigateToVerificationPending.invoke() } - } - - @Test - fun testShowVerificationInProgress() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) - ) - - // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testOnPurchaseBillingProductClick() = - composeExtension.use { - // Arrange - val clickHandler: (ProductId) -> Unit = mockk(relaxed = true) - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID") - every { mockPaymentProduct.status } returns null - initScreen( - state = - AccountUiState.default() - .copy( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - onPurchaseBillingProductClick = clickHandler, - ) - - // Act - onNodeWithText("Add 30 days time ($10)").performClick() - - // Assert - verify { clickHandler.invoke(ProductId("PRODUCT_ID")) } - } - companion object { private const val DUMMY_DEVICE_NAME = "fake_name" private val DUMMY_ACCOUNT_NUMBER = AccountNumber("1234123412341234") diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt index 192daa7199..451c02309f 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt @@ -10,50 +10,53 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.model.TunnelState -import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @OptIn(ExperimentalTestApi::class) class OutOfTimeScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val addTimeViewModel: AddTimeViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { addTimeViewModel } }) + every { addTimeViewModel.uiState } returns + MutableStateFlow<Lc<Unit, AddTimeUiState>>(Lc.Loading(Unit)) } private fun ComposeContext.initScreen( state: OutOfTimeUiState = OutOfTimeUiState(), onDisconnectClick: () -> Unit = {}, - onSitePaymentClick: () -> Unit = {}, onRedeemVoucherClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, - onPurchaseBillingProductClick: (ProductId) -> Unit = {}, - navigateToVerificationPendingDialog: () -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, ) { setContentWithTheme { OutOfTimeScreen( state = state, onDisconnectClick = onDisconnectClick, - onSitePaymentClick = onSitePaymentClick, onRedeemVoucherClick = onRedeemVoucherClick, onSettingsClick = onSettingsClick, onAccountClick = onAccountClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, ) } } @@ -70,7 +73,6 @@ class OutOfTimeScreenTest { substring = true, ) .assertDoesNotExist() - onNodeWithText("Buy credit").assertDoesNotExist() } @Test @@ -91,40 +93,6 @@ class OutOfTimeScreenTest { } @Test - fun testClickSitePaymentButton() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen( - state = OutOfTimeUiState(deviceName = "", showSitePayment = true), - onSitePaymentClick = mockClickListener, - ) - - // Act - onNodeWithText("Buy credit").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test - fun testClickRedeemVoucher() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen( - state = OutOfTimeUiState(deviceName = "", showSitePayment = true), - onRedeemVoucherClick = mockClickListener, - ) - - // Act - onNodeWithText("Redeem voucher").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test fun testClickDisconnect() = composeExtension.use { // Arrange @@ -147,77 +115,13 @@ class OutOfTimeScreenTest { } @Test - fun testShowBillingErrorPaymentButton() = - composeExtension.use { - // Arrange - initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = PaymentState.Error.Billing, - ) - ) - - // Assert - onNodeWithText("Add 30 days time").assertExists() - } - - @Test - fun testShowBillingPaymentAvailable() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns null - initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - ) - ) - - // Assert - onNodeWithText("Add 30 days time ($10)").assertExists() - } - - @Test - fun testShowPendingPayment() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - ) - ) - - // Assert - onNodeWithText("Google Play payment pending").assertExists() - } - - @Test fun testShowPendingPaymentInfoDialog() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - val mockNavigateToVerificationPending: () -> Unit = mockk(relaxed = true) + val mockOnPlayPaymentInfoClick: () -> Unit = mockk(relaxed = true) initScreen( - state = - OutOfTimeUiState( - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - ), - navigateToVerificationPendingDialog = mockNavigateToVerificationPending, + state = OutOfTimeUiState(showSitePayment = true, verificationPending = true), + onPlayPaymentInfoClick = mockOnPlayPaymentInfoClick, ) // Act @@ -225,52 +129,16 @@ class OutOfTimeScreenTest { onNodeWithTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG).assertExists() // Assert - verify(exactly = 1) { mockNavigateToVerificationPending.invoke() } + verify(exactly = 1) { mockOnPlayPaymentInfoClick.invoke() } } @Test fun testShowVerificationInProgress() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS - initScreen( - state = - OutOfTimeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - showSitePayment = true, - ) - ) - - // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testOnPurchaseBillingProductClick() = - composeExtension.use { - // Arrange - val clickHandler: (ProductId) -> Unit = mockk(relaxed = true) - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID") - every { mockPaymentProduct.status } returns null - initScreen( - state = - OutOfTimeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)), - showSitePayment = true, - ), - onPurchaseBillingProductClick = clickHandler, - ) - - // Act - onNodeWithText("Add 30 days time ($10)").performClick() + initScreen(state = OutOfTimeUiState(showSitePayment = true, verificationPending = true)) // Assert - verify { clickHandler(ProductId("PRODUCT_ID")) } + onNodeWithText("Google Play payment pending").assertExists() } } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt index b3ac54fb73..cf25afee16 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreenTest.kt @@ -9,52 +9,56 @@ import io.mockk.MockKAnnotations import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension import net.mullvad.mullvadvpn.compose.setContentWithTheme -import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.TunnelState -import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension +import org.koin.core.context.loadKoinModules +import org.koin.core.module.dsl.viewModel +import org.koin.dsl.module @OptIn(ExperimentalTestApi::class) class WelcomeScreenTest { @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + private val addTimeViewModel: AddTimeViewModel = mockk(relaxed = true) + @BeforeEach fun setup() { MockKAnnotations.init(this) + loadKoinModules(module { viewModel { addTimeViewModel } }) + every { addTimeViewModel.uiState } returns + MutableStateFlow<Lc<Unit, AddTimeUiState>>(Lc.Loading(Unit)) } private fun ComposeContext.initScreen( - state: WelcomeUiState = WelcomeUiState(), - onSitePaymentClick: () -> Unit = {}, + state: Lc<Unit, WelcomeUiState> = Lc.Loading(Unit), onRedeemVoucherClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit = {}, onDisconnectClick: () -> Unit = {}, navigateToDeviceInfoDialog: () -> Unit = {}, - navigateToVerificationPendingDialog: () -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit = {}, ) { setContentWithTheme { WelcomeScreen( state = state, - onSitePaymentClick = onSitePaymentClick, onRedeemVoucherClick = onRedeemVoucherClick, onSettingsClick = onSettingsClick, onAccountClick = onAccountClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, navigateToDeviceInfoDialog = navigateToDeviceInfoDialog, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, onDisconnectClick = onDisconnectClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, ) } } @@ -82,7 +86,6 @@ class WelcomeScreenTest { substring = true, ) .assertDoesNotExist() - onNodeWithText("Buy credit").assertDoesNotExist() } @Test @@ -91,108 +94,38 @@ class WelcomeScreenTest { // Arrange val rawAccountNumber = AccountNumber("1111222233334444") val expectedAccountNumber = "1111 2222 3333 4444" - initScreen(state = WelcomeUiState(accountNumber = rawAccountNumber)) - - // Assert - onNodeWithText(expectedAccountNumber).assertExists() - } - - @Test - fun testClickSitePaymentButton() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen( - state = WelcomeUiState(showSitePayment = true), - onSitePaymentClick = mockClickListener, - ) - - // Act - onNodeWithText("Buy credit").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test - fun testClickRedeemVoucher() = - composeExtension.use { - // Arrange - val mockClickListener: () -> Unit = mockk(relaxed = true) - initScreen(state = WelcomeUiState(), onRedeemVoucherClick = mockClickListener) - - // Act - onNodeWithText("Redeem voucher").performClick() - - // Assert - verify(exactly = 1) { mockClickListener.invoke() } - } - - @Test - fun testShowBillingErrorPaymentButton() = - composeExtension.use { - // Arrange - initScreen( - state = WelcomeUiState().copy(billingPaymentState = PaymentState.Error.Billing) - ) - - // Assert - onNodeWithText("Add 30 days time").assertExists() - } - - @Test - fun testShowBillingPaymentAvailable() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns null initScreen( state = WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) + tunnelState = TunnelState.Disconnected(), + accountNumber = rawAccountNumber, + deviceName = null, + showSitePayment = false, + verificationPending = false, + ) + .toLc() ) // Assert - onNodeWithText("Add 30 days time ($10)").assertExists() - } - - @Test - fun testShowPendingPayment() = - composeExtension.use { - // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING - initScreen( - state = - WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) - ) - - // Assert - onNodeWithText("Google Play payment pending").assertExists() + onNodeWithText(expectedAccountNumber).assertExists() } @Test fun testShowPendingPaymentInfoDialog() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.PENDING val mockShowPendingInfo = mockk<() -> Unit>(relaxed = true) initScreen( state = WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - navigateToVerificationPendingDialog = mockShowPendingInfo, + tunnelState = TunnelState.Disconnected(), + accountNumber = null, + deviceName = null, + showSitePayment = false, + verificationPending = true, + ) + .toLc(), + onPlayPaymentInfoClick = mockShowPendingInfo, ) // Act @@ -206,45 +139,20 @@ class WelcomeScreenTest { fun testShowVerificationInProgress() = composeExtension.use { // Arrange - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.status } returns PaymentStatus.VERIFICATION_IN_PROGRESS - initScreen( state = WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ) + tunnelState = TunnelState.Disconnected(), + accountNumber = null, + deviceName = null, + showSitePayment = false, + verificationPending = true, + ) + .toLc() ) // Assert - onNodeWithText("Verifying purchase").assertExists() - } - - @Test - fun testOnPurchaseBillingProductClick() = - composeExtension.use { - // Arrange - val clickHandler: (ProductId) -> Unit = mockk(relaxed = true) - val mockPaymentProduct: PaymentProduct = mockk() - every { mockPaymentProduct.price } returns ProductPrice("$10") - every { mockPaymentProduct.productId } returns ProductId("PRODUCT_ID") - every { mockPaymentProduct.status } returns null - initScreen( - state = - WelcomeUiState( - billingPaymentState = - PaymentState.PaymentAvailable(listOf(mockPaymentProduct)) - ), - onPurchaseBillingProductClick = clickHandler, - ) - - // Act - onNodeWithText("Add 30 days time ($10)").performClick() - - // Assert - verify { clickHandler(ProductId("PRODUCT_ID")) } + onNodeWithText("Google Play payment pending").assertExists() } @Test @@ -255,7 +163,15 @@ class WelcomeScreenTest { val tunnelState: TunnelState = mockk(relaxed = true) every { tunnelState.isSecured() } returns true initScreen( - state = WelcomeUiState(tunnelState = tunnelState), + state = + WelcomeUiState( + tunnelState = tunnelState, + accountNumber = null, + deviceName = null, + showSitePayment = false, + verificationPending = false, + ) + .toLc(), onDisconnectClick = clickHandler, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt index 250ba7da99..8aa7d762b5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.button +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -12,12 +13,14 @@ import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow @@ -204,6 +207,51 @@ fun PrimaryTextButton( } @Composable +fun NegativeOutlinedButton( + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, + colors: ButtonColors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onError, + disabledContentColor = MaterialTheme.colorScheme.onError.copy(alpha = Alpha20), + ), + border: BorderStroke = + BorderStroke( + width = Dimens.outLineButtonBorderWidth, + color = MaterialTheme.colorScheme.error, + ), + shape: Shape = MaterialTheme.shapes.large, + isEnabled: Boolean = true, + isLoading: Boolean = false, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + val hasIcon = leadingIcon != null || trailingIcon != null + OutlinedButton( + onClick = onClick, + modifier = modifier.wrapContentHeight().width(IntrinsicSize.Max), + colors = colors, + enabled = !isLoading && isEnabled, + border = border, + contentPadding = + if (hasIcon) { + PaddingValues(vertical = Dimens.buttonSpacing) + } else { + ButtonDefaults.TextButtonContentPadding + }, + shape = shape, + ) { + BaseButtonContent( + text = text, + isLoading = isLoading, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) + } +} + +@Composable private fun BaseButton( onClick: () -> Unit, colors: ButtonColors, @@ -238,6 +286,22 @@ private fun BaseButton( } @Composable +fun SmallPrimaryButton( + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, + colors: ButtonColors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = Alpha20), + disabledContainerColor = MaterialTheme.colorScheme.primaryDisabled, + ), +) { + Button(onClick = onClick, modifier = modifier, colors = colors) { Text(text = text) } +} + +@Composable private fun RowScope.BaseButtonContent( text: String, isLoading: Boolean, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt index 0a14272afd..46d5cdb2ae 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt @@ -26,11 +26,17 @@ private fun PreviewSitePaymentButton() { } @Composable -fun SitePaymentButton(onClick: () -> Unit, isEnabled: Boolean, modifier: Modifier = Modifier) { +fun SitePaymentButton( + onClick: () -> Unit, + isEnabled: Boolean, + modifier: Modifier = Modifier, + isLoading: Boolean = false, +) { ExternalButton( onClick = onClick, modifier = modifier, isEnabled = isEnabled, + isLoading = isLoading, text = stringResource(id = R.string.buy_credit), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt index 329f66afb4..4bef6416ee 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt @@ -1,10 +1,13 @@ package net.mullvad.mullvadvpn.compose.cell +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -14,18 +17,35 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.component.SpacedColumn import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens @Preview @Composable private fun PreviewIconCell() { - AppTheme { IconCell(imageVector = Icons.Default.Add, title = "Add") } + AppTheme { + SpacedColumn { + IconCell(imageVector = Icons.Default.Add, title = "Add") + IconCell( + imageVector = Icons.Default.Remove, + title = "Remove", + endIcon = { + Icon( + imageVector = Icons.Default.Error, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary, + ) + }, + ) + } + } } @Composable fun IconCell( imageVector: ImageVector?, + endIcon: @Composable ColumnScope.() -> Unit = {}, title: String, modifier: Modifier = Modifier, contentDescription: String? = null, @@ -37,7 +57,7 @@ fun IconCell( ) { BaseCell( headlineContent = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { imageVector?.let { Icon( imageVector = imageVector, @@ -49,6 +69,7 @@ fun IconCell( BaseCellTitle(title = title, style = titleStyle, textColor = titleColor) } }, + bodyView = endIcon, onCellClicked = onClick, background = background, isRowEnabled = enabled, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheet.kt new file mode 100644 index 0000000000..ef92d131f5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AddTimeBottomSheet.kt @@ -0,0 +1,506 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.activity.compose.LocalActivity +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Redeem +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.Density +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.SmallPrimaryButton +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook +import net.mullvad.mullvadvpn.compose.preview.AddMoreTimeUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.lib.payment.ProductIds.OneMonth +import net.mullvad.mullvadvpn.lib.payment.ProductIds.ThreeMonths +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.viewmodel.AddMoreTimeSideEffect +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel +import org.koin.androidx.compose.koinViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Preview( + "Loading|oss|LoadingSitePayment|" + + "PaymentLoading|NoPayment|NoProductsFound|PaymentAvailable|PaymentPending|PaymentError" +) +@Composable +private fun PreviewPaymentBottomSheet( + @PreviewParameter(AddMoreTimeUiStatePreviewParameterProvider::class) + state: Lc<Unit, AddTimeUiState> +) { + AppTheme { + AddTimeBottomSheetContent( + state = state, + sheetState = + SheetState( + skipPartiallyExpanded = true, + density = Density(1f), + initialValue = SheetValue.Expanded, + ), + onPurchaseBillingProductClick = {}, + onPlayPaymentInfoClick = {}, + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + closeBottomSheet = {}, + onRetryFetchProducts = {}, + resetPurchaseState = {}, + closeSheetAndResetPurchaseState = {}, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTimeBottomSheet( + visible: Boolean, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, + onHideBottomSheet: () -> Unit, +) { + val viewModel: AddTimeViewModel = koinViewModel<AddTimeViewModel>() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate -> + if (animate) { + scope.launch { sheetState.hide() }.invokeOnCompletion { onHideBottomSheet() } + } else { + onHideBottomSheet() + } + } + + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() + CollectSideEffectWithLifecycle(viewModel.uiSideEffect) { sideEffect -> + when (sideEffect) { + is AddMoreTimeSideEffect.OpenAccountManagementPageInBrowser -> { + openAccountPage(sideEffect.token) + onCloseBottomSheet(true) + } + } + } + + val activity = LocalActivity.current + if (visible) { + AddTimeBottomSheetContent( + state = uiState, + sheetState = sheetState, + onPurchaseBillingProductClick = { + viewModel.startBillingPayment(productId = it, activityProvider = { activity!! }) + }, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = viewModel::onManageAccountClick, + onRetryFetchProducts = viewModel::fetchPaymentAvailability, + onRedeemVoucherClick = onRedeemVoucherClick, + resetPurchaseState = { viewModel.onClosePurchaseResultDialog(false) }, + closeSheetAndResetPurchaseState = { + viewModel.onClosePurchaseResultDialog(it) + onCloseBottomSheet(true) + }, + closeBottomSheet = onCloseBottomSheet, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddTimeBottomSheetContent( + state: Lc<Unit, AddTimeUiState>, + sheetState: SheetState, + onPurchaseBillingProductClick: (ProductId) -> Unit = {}, + onPlayPaymentInfoClick: () -> Unit, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onRetryFetchProducts: () -> Unit, + resetPurchaseState: () -> Unit, + closeSheetAndResetPurchaseState: (Boolean) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + val backgroundColor = MaterialTheme.colorScheme.surfaceContainer + val onBackgroundColor = MaterialTheme.colorScheme.onSurface + MullvadModalBottomSheet( + sheetState = sheetState, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onDismissRequest = { closeBottomSheet(false) }, + ) { + when (state) { + is Lc.Loading -> + Loading(backgroundColor = backgroundColor, onBackgroundColor = onBackgroundColor) + is Lc.Content -> + Content( + state = state.value, + internetBlocked = state.value.tunnelStateBlocked, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = onSitePaymentClick, + onRedeemVoucherClick = onRedeemVoucherClick, + onRetryFetchProducts = onRetryFetchProducts, + closeBottomSheet = closeBottomSheet, + resetPurchaseState = resetPurchaseState, + closeSheetAndResetPurchaseState = closeSheetAndResetPurchaseState, + ) + } + } +} + +@Composable +private fun Content( + state: AddTimeUiState, + internetBlocked: Boolean, + backgroundColor: Color, + onBackgroundColor: Color, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onPlayPaymentInfoClick: () -> Unit, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onRetryFetchProducts: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, + resetPurchaseState: () -> Unit, + closeSheetAndResetPurchaseState: (Boolean) -> Unit, +) { + AnimatedContent(targetState = state) { state -> + Column { + if (state.purchaseState != null) { + PurchaseState( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + purchaseState = state.purchaseState, + resetPurchaseState = resetPurchaseState, + closeSheetAndResetPurchaseState = closeSheetAndResetPurchaseState, + ) + } else { + Products( + billingPaymentState = state.billingPaymentState, + showSitePayment = state.showSitePayment, + internetBlocked = internetBlocked, + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + onSitePaymentClick = onSitePaymentClick, + onRedeemVoucherClick = onRedeemVoucherClick, + onRetryFetchProducts = onRetryFetchProducts, + closeBottomSheet = closeBottomSheet, + ) + } + } + } +} + +@Composable +private fun ColumnScope.PurchaseState( + backgroundColor: Color, + onBackgroundColor: Color, + purchaseState: PurchaseState, + resetPurchaseState: () -> Unit, + closeSheetAndResetPurchaseState: (Boolean) -> Unit, +) { + when (purchaseState) { + // Fetching products and obfuscated id loading state + PurchaseState.Connecting -> { + PurchaseStateLoading(title = stringResource(R.string.connecting)) + } + PurchaseState.VerificationStarted -> { + PurchaseStateLoading(title = stringResource(R.string.loading_verifying)) + } + // Pending state + PurchaseState.VerifyingPurchase -> { + PurchaseStateVerification( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + closeSheet = closeSheetAndResetPurchaseState, + ) + } + // Success state + is PurchaseState.Success -> { + PurchaseStateSuccess( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + productId = purchaseState.productId, + onSuccessfulPurchase = closeSheetAndResetPurchaseState, + ) + } + // Error states + is PurchaseState.Error.TransactionIdError -> { + PurchaseStateError( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + title = stringResource(R.string.payment_obfuscation_id_error_dialog_title), + message = stringResource(R.string.payment_obfuscation_id_error_dialog_message), + resetPurchaseState = resetPurchaseState, + ) + } + is PurchaseState.Error.OtherError -> { + PurchaseStateError( + backgroundColor = backgroundColor, + onBackgroundColor = onBackgroundColor, + title = stringResource(R.string.payment_billing_error_dialog_title), + message = stringResource(R.string.payment_billing_error_dialog_message), + resetPurchaseState = resetPurchaseState, + ) + } + } +} + +@Composable +private fun PurchaseStateVerification( + onBackgroundColor: Color, + backgroundColor: Color, + closeSheet: (Boolean) -> Unit, +) { + SheetTitle( + title = stringResource(id = R.string.verifying_purchase), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenTopMargin), + ) { + Text( + text = stringResource(id = R.string.payment_pending_dialog_message), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + SmallPrimaryButton( + text = stringResource(R.string.close), + onClick = { closeSheet(false) }, + modifier = Modifier.padding(top = Dimens.mediumPadding), + ) + } +} + +@Composable +private fun PurchaseStateLoading(title: String) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(all = Dimens.sideMargin), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(Dimens.mediumPadding)) + MullvadLinearProgressIndicator() + } +} + +@Composable +private fun PurchaseStateSuccess( + onBackgroundColor: Color, + backgroundColor: Color, + productId: ProductId, + onSuccessfulPurchase: (Boolean) -> Unit, +) { + SheetTitle( + title = stringResource(id = R.string.time_added), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenTopMargin), + ) { + Text( + text = + when (productId.value) { + OneMonth -> stringResource(R.string.days_were_added_30) + ThreeMonths -> stringResource(R.string.days_were_added_90) + else -> { + error("Unknown product: $productId") + } + }, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + SmallPrimaryButton( + text = stringResource(R.string.close), + onClick = { onSuccessfulPurchase(true) }, + modifier = Modifier.padding(top = Dimens.mediumPadding), + ) + } +} + +@Composable +private fun ColumnScope.PurchaseStateError( + onBackgroundColor: Color, + backgroundColor: Color, + title: String, + message: String, + resetPurchaseState: () -> Unit, +) { + SheetTitle( + title = title, + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) + Text( + text = message, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = Dimens.sideMargin), + ) + SmallPrimaryButton( + text = stringResource(android.R.string.ok), + onClick = resetPurchaseState, + modifier = Modifier.padding(top = Dimens.mediumPadding).align(Alignment.CenterHorizontally), + ) +} + +@Composable +private fun Products( + billingPaymentState: PaymentState?, + internetBlocked: Boolean, + showSitePayment: Boolean, + backgroundColor: Color, + onBackgroundColor: Color, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onPlayPaymentInfoClick: () -> Unit, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onRetryFetchProducts: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit, +) { + SheetTitle( + title = stringResource(id = R.string.add_time), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + billingPaymentState?.let { + PlayPayment( + modifier = Modifier.fillMaxWidth(), + billingPaymentState = billingPaymentState, + onBackgroundColor = onBackgroundColor, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onInfoClick = onPlayPaymentInfoClick, + onRetryFetchProducts = onRetryFetchProducts, + ) + } + if (showSitePayment) { + if (internetBlocked) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.padding(start = Dimens.cellStartPadding, end = Dimens.cellStartPadding), + ) { + Icon( + modifier = Modifier.size(Dimens.smallIconSize), + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.app_is_blocking_internet), + modifier = Modifier.padding(start = Dimens.miniPadding), + ) + } + } + IconCell( + imageVector = Icons.Outlined.Sell, + title = stringResource(id = R.string.buy_credit), + onClick = { onSitePaymentClick() }, + titleColor = + onBackgroundColor.copy( + alpha = if (internetBlocked) AlphaDisabled else AlphaVisible + ), + background = + backgroundColor.copy(alpha = if (internetBlocked) AlphaDisabled else AlphaVisible), + enabled = !internetBlocked, + endIcon = { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + tint = + onBackgroundColor.copy( + alpha = if (internetBlocked) AlphaDisabled else AlphaVisible + ), + contentDescription = null, + ) + }, + ) + HorizontalDivider( + modifier = Modifier.height(Dimens.thinBorderWidth), + color = onBackgroundColor, + ) + } + IconCell( + imageVector = Icons.Default.Redeem, + title = stringResource(id = R.string.redeem_voucher), + titleColor = onBackgroundColor, + onClick = { + onRedeemVoucherClick() + closeBottomSheet(true) + }, + background = backgroundColor, + ) +} + +@Composable +private fun ColumnScope.Loading(onBackgroundColor: Color, backgroundColor: Color) { + SheetTitle( + title = stringResource(id = R.string.add_time), + onBackgroundColor = onBackgroundColor, + backgroundColor = backgroundColor, + ) + MullvadCircularProgressIndicatorLarge(modifier = Modifier.align(Alignment.CenterHorizontally)) +} + +@Composable +private fun SheetTitle(title: String, onBackgroundColor: Color, backgroundColor: Color) { + HeaderCell(text = title, background = backgroundColor) + HorizontalDivider( + color = onBackgroundColor, + modifier = Modifier.padding(horizontal = Dimens.mediumPadding), + ) + Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LinearProgressIndicator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LinearProgressIndicator.kt new file mode 100644 index 0000000000..8a99ebbf59 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LinearProgressIndicator.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun MullvadLinearProgressIndicator( + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onPrimary, + trackColor: Color = MaterialTheme.colorScheme.primary, +) { + LinearProgressIndicator( + modifier = modifier.fillMaxWidth(), + color = color, + trackColor = trackColor, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt index acd8f00443..b71079be67 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/PlayPayment.kt @@ -1,190 +1,244 @@ package net.mullvad.mullvadvpn.compose.component +import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Sell +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.VariantButton +import net.mullvad.mullvadvpn.compose.button.SmallPrimaryButton +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.preview.PlayPaymentPaymentStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.payment.ProductIds import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaDisabled import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG -@Preview +@Preview( + "Loading|NoPayment|NoProductsFound|Error.Generic|Error.Billing" + + "|PaymentAvailable|PaymentAvailable.Pending|PaymentAvailable.VerificationInProgress" +) @Composable -private fun PreviewPlayPaymentPaymentAvailable() { +private fun PreviewPlayPayment( + @PreviewParameter(PlayPaymentPaymentStatePreviewParameterProvider::class) state: PaymentState +) { AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { + Column(modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainer)) { PlayPayment( - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf( - PaymentProduct( - productId = ProductId("test"), - price = ProductPrice("$10"), - status = null, - ) - ) - ), + billingPaymentState = state, + onBackgroundColor = MaterialTheme.colorScheme.onSurface, onPurchaseBillingProductClick = {}, + onRetryFetchProducts = {}, onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), ) } } } -@Preview @Composable -private fun PreviewPlayPaymentLoading() { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { - PlayPayment( - billingPaymentState = PaymentState.Loading, - onPurchaseBillingProductClick = {}, - onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), +fun PlayPayment( + billingPaymentState: PaymentState, + onBackgroundColor: Color, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onRetryFetchProducts: () -> Unit, + onInfoClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (billingPaymentState) { + PaymentState.Loading -> { + Loading(modifier = modifier) + } + PaymentState.NoPayment, + PaymentState.NoProductsFounds -> { + // Show nothing + return + } + is PaymentState.PaymentAvailable -> { + PaymentAvailable( + modifier = modifier, + billingPaymentState = billingPaymentState, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onInfoClick = onInfoClick, ) } - } -} - -@Preview -@Composable -private fun PreviewPlayPaymentPaymentPending() { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { - PlayPayment( - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf( - PaymentProduct( - productId = ProductId("test"), - price = ProductPrice("$10"), - status = PaymentStatus.PENDING, - ) - ) - ), - onPurchaseBillingProductClick = {}, - onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), + is PaymentState.Error.Generic -> { + Error( + modifier = modifier, + message = stringResource(id = R.string.failed_to_load_products), + retryFetchProducts = onRetryFetchProducts, + ) + } + is PaymentState.Error.Billing -> { + Error( + modifier = modifier, + message = stringResource(id = R.string.in_app_products_unavailable), + retryFetchProducts = onRetryFetchProducts, ) } } + HorizontalDivider(color = onBackgroundColor, thickness = Dimens.thinBorderWidth) } -@Preview @Composable -private fun PreviewPlayPaymentVerificationInProgress() { - AppTheme { - Box(modifier = Modifier.background(color = MaterialTheme.colorScheme.surface)) { - PlayPayment( - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf( - PaymentProduct( - productId = ProductId("test"), - price = ProductPrice("$10"), - status = PaymentStatus.VERIFICATION_IN_PROGRESS, - ) - ) - ), - onPurchaseBillingProductClick = {}, - onInfoClick = {}, - modifier = Modifier.padding(Dimens.screenBottomMargin), - ) - } +private fun Loading(modifier: Modifier = Modifier) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenTopMargin), + ) { + Text( + text = stringResource(id = R.string.loading_products), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(Dimens.mediumPadding)) + MullvadLinearProgressIndicator() } } @Composable -fun PlayPayment( - billingPaymentState: PaymentState, +private fun PaymentAvailable( + billingPaymentState: PaymentState.PaymentAvailable, onPurchaseBillingProductClick: (ProductId) -> Unit, onInfoClick: () -> Unit, modifier: Modifier = Modifier, ) { - when (billingPaymentState) { - PaymentState.Loading -> { - Column(modifier = modifier.fillMaxWidth()) { - MullvadCircularProgressIndicatorSmall(modifier = modifier) + val statusMessage = billingPaymentState.products.status()?.message() + Column( + modifier = + modifier + .clickable(enabled = statusMessage != null, onClick = onInfoClick) + .testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG) + ) { + val enabled = statusMessage == null + statusMessage?.let { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.padding(start = Dimens.cellStartPadding, end = Dimens.cellStartPadding), + ) { + Icon( + modifier = Modifier.size(Dimens.smallIconSize), + imageVector = Icons.Outlined.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = statusMessage, + modifier = Modifier.padding(start = Dimens.miniPadding), + ) } } - PaymentState.NoPayment, - PaymentState.NoProductsFounds -> { - // Show nothing - } - is PaymentState.PaymentAvailable -> { + Column { billingPaymentState.products.forEach { product -> - Column(modifier = modifier) { - val statusMessage = - when (product.status) { - PaymentStatus.PENDING -> - stringResource(id = R.string.payment_status_pending) - PaymentStatus.VERIFICATION_IN_PROGRESS -> - stringResource(id = R.string.verifying_purchase) - else -> null - } - statusMessage?.let { - Row(verticalAlignment = Alignment.Bottom) { - Text( - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - text = statusMessage, - modifier = Modifier.padding(bottom = Dimens.smallPadding), - ) - IconButton( - onClick = onInfoClick, - modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG), - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurface, + IconCell( + background = MaterialTheme.colorScheme.surfaceContainer, + titleColor = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaDisabled) + }, + imageVector = Icons.Outlined.Sell, + title = + when (product.productId.value) { + ProductIds.OneMonth -> + stringResource( + id = R.string.add_30_days_time_x, + product.price.value, ) + ProductIds.ThreeMonths -> + stringResource( + id = R.string.add_90_days_time_x, + product.price.value, + ) + else -> { + // We have somehow requested a product that is not supported + error("ProductId ${product.productId.value} is not supported") } - } - } - VariantButton( - text = - stringResource(id = R.string.add_30_days_time_x, product.price.value), - onClick = { onPurchaseBillingProductClick(product.productId) }, - isEnabled = product.status == null, - ) - } - } - } - // Show the button without the price - is PaymentState.Error -> { - Column(modifier = modifier) { - VariantButton( - text = stringResource(id = R.string.add_30_days_time), - onClick = { onPurchaseBillingProductClick(ProductId(ProductIds.OneMonth)) }, + }, + endIcon = { + Image( + painter = + painterResource( + R.drawable.google_pay_primary_logo_logo_svgrepo_com + ), + contentDescription = null, + modifier = + Modifier.height(Dimens.payIconHeight) + .background( + MaterialTheme.colorScheme.onSurface, + MaterialTheme.shapes.extraLarge, + ) + .padding(all = Dimens.miniPadding), + ) + }, + onClick = { onPurchaseBillingProductClick(product.productId) }, + enabled = enabled, ) } } } } + +@Composable +private fun Error(modifier: Modifier, message: String, retryFetchProducts: () -> Unit) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + modifier.padding(vertical = Dimens.screenTopMargin, horizontal = Dimens.sideMargin), + ) { + Text( + text = message, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = Dimens.smallPadding), + ) + SmallPrimaryButton(text = stringResource(R.string.retry), onClick = retryFetchProducts) + } +} + +private fun List<PaymentProduct>.status(): PaymentStatus? { + return this.firstOrNull { it.status != null }?.status +} + +@Composable +private fun PaymentStatus.message(): String = + when (this) { + PaymentStatus.PENDING -> stringResource(id = R.string.payment_status_pending_long) + + PaymentStatus.VERIFICATION_IN_PROGRESS -> stringResource(id = R.string.verifying_purchase) + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt deleted file mode 100644 index bf9de7e449..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialog.kt +++ /dev/null @@ -1,219 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog.payment - -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.ramcosta.composedestinations.annotation.Destination -import com.ramcosta.composedestinations.annotation.RootGraph -import com.ramcosta.composedestinations.result.ResultBackNavigator -import com.ramcosta.composedestinations.spec.DestinationStyle -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.PrimaryButton -import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge -import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.theme.AppTheme -import net.mullvad.mullvadvpn.util.getActivity -import net.mullvad.mullvadvpn.viewmodel.PaymentUiSideEffect -import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel -import org.koin.androidx.compose.koinViewModel - -@Preview -@Composable -private fun PreviewPaymentDialogPurchaseCompleted() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.payment_completed_dialog_title, - message = R.string.payment_completed_dialog_message, - icon = PaymentDialogIcon.SUCCESS, - confirmAction = PaymentDialogAction.Close, - successfulPayment = true, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogPurchasePending() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.verifying_purchase, - message = R.string.payment_pending_dialog_message, - confirmAction = PaymentDialogAction.Close, - closeOnDismiss = true, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogGenericError() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.error_occurred, - message = R.string.try_again, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogLoading() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.connecting, - icon = PaymentDialogIcon.LOADING, - closeOnDismiss = false, - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Preview -@Composable -private fun PreviewPaymentDialogPaymentAvailabilityError() { - AppTheme { - PaymentDialog( - paymentDialogData = - PaymentDialogData( - title = R.string.payment_billing_error_dialog_title, - message = R.string.payment_billing_error_dialog_message, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - dismissAction = PaymentDialogAction.RetryPurchase(productId = ProductId("test")), - ), - retryPurchase = {}, - onCloseDialog = {}, - ) - } -} - -@Destination<RootGraph>(style = DestinationStyle.Dialog::class) -@Composable -fun Payment(productId: ProductId, resultBackNavigator: ResultBackNavigator<Boolean>) { - val vm = koinViewModel<PaymentViewModel>() - val state by vm.uiState.collectAsStateWithLifecycle() - - CollectSideEffectWithLifecycle(vm.uiSideEffect) { sideEffect -> - when (sideEffect) { - PaymentUiSideEffect.PaymentCancelled -> resultBackNavigator.navigateBack(result = false) - } - } - - val context = LocalContext.current - LaunchedEffect(Unit) { vm.startBillingPayment(productId) { context.getActivity()!! } } - - val dialogData = state.paymentDialogData - if (dialogData != null) { - PaymentDialog( - paymentDialogData = dialogData, - retryPurchase = { vm.startBillingPayment(it) { context.getActivity()!! } }, - onCloseDialog = { resultBackNavigator.navigateBack(result = it) }, - ) - } -} - -@Composable -fun PaymentDialog( - paymentDialogData: PaymentDialogData, - retryPurchase: (ProductId) -> Unit, - onCloseDialog: (isPaymentSuccessful: Boolean) -> Unit, -) { - val clickResolver: (action: PaymentDialogAction) -> Unit = { - when (it) { - is PaymentDialogAction.RetryPurchase -> retryPurchase(it.productId) - is PaymentDialogAction.Close -> onCloseDialog(paymentDialogData.successfulPayment) - } - } - AlertDialog( - icon = { - when (paymentDialogData.icon) { - PaymentDialogIcon.SUCCESS -> - Icon( - painter = painterResource(id = R.drawable.icon_success), - contentDescription = null, - ) - PaymentDialogIcon.FAIL -> - Icon( - painter = painterResource(id = R.drawable.icon_fail), - contentDescription = null, - ) - PaymentDialogIcon.LOADING -> MullvadCircularProgressIndicatorLarge() - else -> {} - } - }, - title = { - paymentDialogData.title?.let { - Text( - text = stringResource(id = paymentDialogData.title), - style = MaterialTheme.typography.headlineSmall, - ) - } - }, - text = - paymentDialogData.message?.let { - { - Text( - text = stringResource(id = paymentDialogData.message), - style = MaterialTheme.typography.bodySmall, - ) - } - }, - containerColor = MaterialTheme.colorScheme.surface, - titleContentColor = MaterialTheme.colorScheme.onSurface, - iconContentColor = Color.Unspecified, - textContentColor = MaterialTheme.colorScheme.onSurfaceVariant, - onDismissRequest = { - if (paymentDialogData.closeOnDismiss) { - onCloseDialog(paymentDialogData.successfulPayment) - } - }, - dismissButton = { - paymentDialogData.dismissAction?.let { - PrimaryButton( - text = stringResource(id = it.message), - onClick = { clickResolver(it) }, - ) - } - }, - confirmButton = { - paymentDialogData.confirmAction?.let { - PrimaryButton( - text = stringResource(id = it.message), - onClick = { clickResolver(it) }, - ) - } - }, - ) -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt deleted file mode 100644 index 2bdcda0507..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/payment/PaymentDialogData.kt +++ /dev/null @@ -1,26 +0,0 @@ -package net.mullvad.mullvadvpn.compose.dialog.payment - -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.payment.model.ProductId - -data class PaymentDialogData( - val title: Int? = null, - val message: Int? = null, - val icon: PaymentDialogIcon? = null, - val confirmAction: PaymentDialogAction? = null, - val dismissAction: PaymentDialogAction? = null, - val closeOnDismiss: Boolean = true, - val successfulPayment: Boolean = false, -) - -sealed class PaymentDialogAction(val message: Int) { - data object Close : PaymentDialogAction(R.string.got_it) - - data class RetryPurchase(val productId: ProductId) : PaymentDialogAction(R.string.try_again) -} - -enum class PaymentDialogIcon { - SUCCESS, - FAIL, - LOADING, -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt index f40d0697ab..6b981d5d7d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AccountUiStatePreviewParameterProvider.kt @@ -3,62 +3,38 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider import java.time.ZonedDateTime import java.time.format.DateTimeFormatter -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.model.AccountNumber -import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc import net.mullvad.mullvadvpn.viewmodel.AccountUiState -class AccountUiStatePreviewParameterProvider : PreviewParameterProvider<AccountUiState> { +class AccountUiStatePreviewParameterProvider : PreviewParameterProvider<Lc<Unit, AccountUiState>> { override val values = sequenceOf( + Lc.Loading(Unit), AccountUiState( - deviceName = "Test Name", - accountNumber = AccountNumber("1234123412341234"), - accountExpiry = - ZonedDateTime.parse( - "2050-12-01T00:00:00.000Z", - DateTimeFormatter.ISO_ZONED_DATE_TIME, - ), - showSitePayment = true, - billingPaymentState = - PaymentState.PaymentAvailable( - listOf( - PaymentProduct( - ProductId("productId"), - price = ProductPrice("34 SEK"), - status = null, - ), - PaymentProduct( - ProductId("productId_pending"), - price = ProductPrice("34 SEK"), - status = PaymentStatus.PENDING, - ), - ) - ), - showLogoutLoading = false, - showManageAccountLoading = false, - ) - ) + generateOtherStates() - - private fun generateOtherStates(): Sequence<AccountUiState> = - sequenceOf( - PaymentState.Loading, - PaymentState.NoPayment, - PaymentState.NoProductsFounds, - PaymentState.Error.Billing, - ) - .map { state -> - AccountUiState( deviceName = "Test Name", accountNumber = AccountNumber("1234123412341234"), - accountExpiry = null, - showSitePayment = false, - billingPaymentState = state, + accountExpiry = + ZonedDateTime.parse( + "2050-12-01T00:00:00.000Z", + DateTimeFormatter.ISO_ZONED_DATE_TIME, + ), showLogoutLoading = false, - showManageAccountLoading = false, + verificationPending = true, + ) + .toLc(), + AccountUiState( + deviceName = "Test Name", + accountNumber = AccountNumber("1234123412341234"), + accountExpiry = + ZonedDateTime.parse( + "2050-12-01T00:00:00.000Z", + DateTimeFormatter.ISO_ZONED_DATE_TIME, + ), + showLogoutLoading = true, + verificationPending = false, ) - } + .toLc(), + ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AddMoreTimeUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AddMoreTimeUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..1a2f5a7024 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AddMoreTimeUiStatePreviewParameterProvider.kt @@ -0,0 +1,80 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc + +class AddMoreTimeUiStatePreviewParameterProvider : + PreviewParameterProvider<Lc<Unit, AddTimeUiState>> { + override val values: Sequence<Lc<Unit, AddTimeUiState>> = + sequenceOf( + Lc.Loading(Unit), + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + showSitePayment = true, + tunnelStateBlocked = false, + ) + .toLc(), + AddTimeUiState( + purchaseState = null, + billingPaymentState = null, + showSitePayment = true, + tunnelStateBlocked = false, + ) + .toLc(), + ) + + generatePaymentStates().map { state -> + AddTimeUiState( + purchaseState = null, + billingPaymentState = state, + showSitePayment = false, + tunnelStateBlocked = false, + ) + .toLc() + } + + private fun generatePaymentStates(): Sequence<PaymentState> = + sequenceOf( + PaymentState.Loading, + PaymentState.NoPayment, + PaymentState.NoProductsFounds, + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = null, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = PaymentStatus.PENDING, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + PaymentState.Error.Billing, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/PlayPaymentPaymentStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/PlayPaymentPaymentStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..04a5422238 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/PlayPaymentPaymentStatePreviewParameterProvider.kt @@ -0,0 +1,63 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice + +class PlayPaymentPaymentStatePreviewParameterProvider : PreviewParameterProvider<PaymentState> { + override val values: Sequence<PaymentState> = + sequenceOf(PaymentState.Loading, PaymentState.Error.Generic, PaymentState.Error.Billing) + + sequenceOf( + // Products available + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = null, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + // Product pending + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = PaymentStatus.PENDING, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + // Product verification in progress + PaymentState.PaymentAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("one_month"), + price = ProductPrice("$10"), + status = PaymentStatus.VERIFICATION_IN_PROGRESS, + ), + PaymentProduct( + productId = ProductId("three_months"), + price = ProductPrice("$30"), + status = null, + ), + ) + ), + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt index 5b4028e6ca..5a26fd4b33 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/SelectLocationsUiStatePreviewParameterProvider.kt @@ -6,23 +6,22 @@ import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.usecase.FilterChip import net.mullvad.mullvadvpn.usecase.ModelOwnership import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc class SelectLocationsUiStatePreviewParameterProvider : PreviewParameterProvider<Lc<Unit, SelectLocationUiState>> { override val values = sequenceOf( Lc.Loading(Unit), - Lc.Content( - SelectLocationUiState( + SelectLocationUiState( filterChips = emptyList(), multihopEnabled = false, relayListType = RelayListType.EXIT, isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), - Lc.Content( - SelectLocationUiState( + .toLc(), + SelectLocationUiState( filterChips = listOf( FilterChip.Ownership(ownership = ModelOwnership.Rented), @@ -33,18 +32,16 @@ class SelectLocationsUiStatePreviewParameterProvider : isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), - Lc.Content( - SelectLocationUiState( + .toLc(), + SelectLocationUiState( filterChips = emptyList(), multihopEnabled = true, relayListType = RelayListType.ENTRY, isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), - Lc.Content( - SelectLocationUiState( + .toLc(), + SelectLocationUiState( filterChips = listOf( FilterChip.Ownership(ownership = ModelOwnership.MullvadOwned), @@ -55,7 +52,7 @@ class SelectLocationsUiStatePreviewParameterProvider : isSearchButtonEnabled = true, isFilterButtonEnabled = true, ) - ), + .toLc(), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt index 79e7ae0069..a3d93eea33 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/WelcomeScreenUiStatePreviewParameterProvider.kt @@ -1,25 +1,32 @@ package net.mullvad.mullvadvpn.compose.preview import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.model.AccountNumber -import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.toLc -class WelcomeScreenUiStatePreviewParameterProvider : PreviewParameterProvider<WelcomeUiState> { +class WelcomeScreenUiStatePreviewParameterProvider : + PreviewParameterProvider<Lc<Unit, WelcomeUiState>> { override val values = sequenceOf( + Lc.Loading(Unit), WelcomeUiState( - tunnelState = TunnelStatePreviewData.generateDisconnectedState(), - accountNumber = AccountNumber("4444555566667777"), - deviceName = "Happy Mole", - billingPaymentState = - PaymentState.PaymentAvailable( - products = - listOf(PaymentProduct(ProductId("product"), ProductPrice("$44"), null)) - ), - ) + tunnelState = TunnelStatePreviewData.generateDisconnectedState(), + accountNumber = AccountNumber("4444555566667777"), + deviceName = "Happy Mole", + showSitePayment = false, + verificationPending = true, + ) + .toLc(), + WelcomeUiState( + tunnelState = + TunnelStatePreviewData.generateConnectedState(featureIndicators = 1, false), + accountNumber = AccountNumber("4444555566667777"), + deviceName = "Happy Mole", + showSitePayment = true, + verificationPending = false, + ) + .toLc(), ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index dfd1f2d8f7..b7ce1ca5d4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -8,15 +8,22 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -30,27 +37,21 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.LoginDestination import com.ramcosta.composedestinations.generated.destinations.ManageDevicesDestination -import com.ramcosta.composedestinations.generated.destinations.PaymentDestination import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient import java.time.ZonedDateTime import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.ExternalButton -import net.mullvad.mullvadvpn.compose.button.NegativeButton +import net.mullvad.mullvadvpn.compose.button.NegativeOutlinedButton import net.mullvad.mullvadvpn.compose.button.PrimaryTextButton -import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton +import net.mullvad.mullvadvpn.compose.component.AddTimeBottomSheet import net.mullvad.mullvadvpn.compose.component.CopyableObfuscationView import net.mullvad.mullvadvpn.compose.component.InformationView import net.mullvad.mullvadvpn.compose.component.MissingPolicy import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton -import net.mullvad.mullvadvpn.compose.component.PlayPayment import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.AccountUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.transitions.AccountTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle @@ -58,30 +59,38 @@ import net.mullvad.mullvadvpn.compose.util.SecureScreenWhileInView import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.lib.common.util.toExpiryDateString -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.ui.tag.MANAGE_DEVICES_BUTTON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc import net.mullvad.mullvadvpn.viewmodel.AccountUiState import net.mullvad.mullvadvpn.viewmodel.AccountViewModel import org.koin.androidx.compose.koinViewModel @OptIn(ExperimentalMaterial3Api::class) -@Preview("PaymentAvailable|Loading|NoPayment|NoProductsFound|Error.Billing") +@Preview("Loading|Content|LogoutLoading") @Composable private fun PreviewAccountScreen( - @PreviewParameter(AccountUiStatePreviewParameterProvider::class) state: AccountUiState + @PreviewParameter(AccountUiStatePreviewParameterProvider::class) state: Lc<Unit, AccountUiState> ) { - AppTheme { AccountScreen(state = state, SnackbarHostState(), {}, {}, {}, {}, {}, {}, {}, {}) } + AppTheme { + AccountScreen( + state = state.contentOrNull(), + snackbarHostState = SnackbarHostState(), + onCopyAccountNumber = {}, + onManageDevicesClick = {}, + onLogoutClick = {}, + onRedeemVoucherClick = {}, + onPlayPaymentInfoClick = {}, + onBackClick = {}, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Destination<RootGraph>(style = AccountTransition::class) @Composable -fun Account( - navigator: DestinationsNavigator, - playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>, -) { +fun Account(navigator: DestinationsNavigator) { val vm = koinViewModel<AccountViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() @@ -109,46 +118,34 @@ fun Account( } } - playPaymentResultRecipient.onNavResult { - when (it) { - NavResult.Canceled -> { - /* Do nothing */ - } - is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) - } - } - AccountScreen( - state = state, + state = state.contentOrNull(), snackbarHostState = snackbarHostState, - onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, onManageDevicesClick = dropUnlessResumed { - state.accountNumber?.let { navigator.navigate(ManageDevicesDestination(it)) } + state.contentOrNull()?.accountNumber?.let { + navigator.navigate(ManageDevicesDestination(it)) + } }, - onManageAccountClick = vm::onManageAccountClick, onLogoutClick = vm::onLogoutClick, onCopyAccountNumber = vm::onCopyAccountNumber, - onBackClick = dropUnlessResumed { navigator.navigateUp() }, - onPurchaseBillingProductClick = - dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) }, - navigateToVerificationPendingDialog = + onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, + onPlayPaymentInfoClick = dropUnlessResumed { navigator.navigate(VerificationPendingDestination) }, + onBackClick = dropUnlessResumed { navigator.navigateUp() }, ) } @ExperimentalMaterial3Api @Composable fun AccountScreen( - state: AccountUiState, + state: AccountUiState?, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onCopyAccountNumber: (String) -> Unit, - onRedeemVoucherClick: () -> Unit, - onManageAccountClick: () -> Unit, onManageDevicesClick: () -> Unit, onLogoutClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit, - navigateToVerificationPendingDialog: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, onBackClick: () -> Unit, ) { // This will enable SECURE_FLAG while this screen is visible to preview screenshot @@ -159,6 +156,16 @@ fun AccountScreen( navigationIcon = { NavigateCloseIconButton(onBackClick) }, snackbarHostState = snackbarHostState, ) { modifier -> + var addTimeBottomSheetState by remember { mutableStateOf<Boolean>(false) } + if (!LocalInspectionMode.current) { + AddTimeBottomSheet( + visible = addTimeBottomSheetState, + onHideBottomSheet = { addTimeBottomSheetState = false }, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + ) + } + Column( horizontalAlignment = Alignment.Start, modifier = @@ -172,50 +179,30 @@ fun AccountScreen( modifier = Modifier.padding(bottom = Dimens.smallPadding).animateContentSize(), ) { DeviceNameRow( - deviceName = state.deviceName ?: "", + deviceName = state?.deviceName ?: "", onManageDevicesClick = onManageDevicesClick, ) AccountNumberRow( - accountNumber = state.accountNumber?.value ?: "", + accountNumber = state?.accountNumber?.value ?: "", onCopyAccountNumber, ) - PaidUntilRow(accountExpiry = state.accountExpiry) - } - - Spacer(modifier = Modifier.weight(1f)) - - state.billingPaymentState?.let { - PlayPayment( - billingPaymentState = state.billingPaymentState, - onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) - }, - onInfoClick = navigateToVerificationPendingDialog, - modifier = Modifier.padding(bottom = Dimens.buttonSpacing), - ) - } - - if (state.showSitePayment) { - ExternalButton( - text = stringResource(id = R.string.manage_account), - onClick = onManageAccountClick, - modifier = Modifier.padding(bottom = Dimens.buttonSpacing), - isLoading = state.showManageAccountLoading, + PaidUntilRow( + accountExpiry = state?.accountExpiry, + verificationPending = state?.verificationPending == true, + onOpenPaymentScreen = { addTimeBottomSheetState = true }, + onInfoClick = onPlayPaymentInfoClick, ) } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, - modifier = Modifier.padding(bottom = Dimens.buttonSpacing), - isEnabled = true, - ) + Spacer(modifier = Modifier.weight(1f)) - NegativeButton( + NegativeOutlinedButton( text = stringResource(id = R.string.log_out), onClick = onLogoutClick, - isLoading = state.showLogoutLoading, + isLoading = state?.showLogoutLoading == true, + modifier = Modifier.fillMaxWidth(), ) } } @@ -260,7 +247,12 @@ private fun AccountNumberRow(accountNumber: String, onCopyAccountNumber: (String } @Composable -private fun PaidUntilRow(accountExpiry: ZonedDateTime?) { +private fun PaidUntilRow( + accountExpiry: ZonedDateTime?, + verificationPending: Boolean, + onOpenPaymentScreen: () -> Unit, + onInfoClick: () -> Unit, +) { Column(modifier = Modifier.fillMaxWidth()) { Text( style = MaterialTheme.typography.labelMedium, @@ -276,6 +268,29 @@ private fun PaidUntilRow(accountExpiry: ZonedDateTime?) { content = accountExpiry?.toExpiryDateString() ?: "", whenMissing = MissingPolicy.SHOW_SPINNER, ) + Spacer(modifier = Modifier.weight(1f)) + PrimaryTextButton( + onClick = onOpenPaymentScreen, + text = stringResource(R.string.add_time), + textDecoration = TextDecoration.Underline, + ) + } + + if (verificationPending) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onInfoClick) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.payment_status_pending_short), + ) + } } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt index 83febe7ea3..6173f1ada9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -4,21 +4,30 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource @@ -33,34 +42,27 @@ import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.AccountDestination import com.ramcosta.composedestinations.generated.destinations.ConnectDestination -import com.ramcosta.composedestinations.generated.destinations.PaymentDestination import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination import com.ramcosta.composedestinations.generated.destinations.SettingsDestination import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.NegativeButton -import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton -import net.mullvad.mullvadvpn.compose.button.SitePaymentButton -import net.mullvad.mullvadvpn.compose.component.PlayPayment +import net.mullvad.mullvadvpn.compose.button.VariantButton +import net.mullvad.mullvadvpn.compose.component.AddTimeBottomSheet import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.OutOfTimeScreenPreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately -import net.mullvad.mullvadvpn.lib.model.ErrorStateCause -import net.mullvad.mullvadvpn.lib.model.TunnelState -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.ui.tag.OUT_OF_TIME_SCREEN_TITLE_TEST_TAG +import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import org.koin.androidx.compose.koinViewModel @@ -69,36 +71,14 @@ import org.koin.androidx.compose.koinViewModel private fun PreviewOutOfTimeScreen( @PreviewParameter(OutOfTimeScreenPreviewParameterProvider::class) state: OutOfTimeUiState ) { - AppTheme { OutOfTimeScreen(state = state, SnackbarHostState(), {}, {}, {}, {}, {}, {}, {}) } + AppTheme { OutOfTimeScreen(state = state, SnackbarHostState(), {}, {}, {}, {}, {}) } } @Destination<RootGraph>(style = HomeTransition::class) @Composable -fun OutOfTime( - navigator: DestinationsNavigator, - redeemVoucherResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>, - playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>, -) { +fun OutOfTime(navigator: DestinationsNavigator) { val vm = koinViewModel<OutOfTimeViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - redeemVoucherResultRecipient.onNavResult { - // If we successfully redeemed a voucher, navigate to Connect screen - if (it is NavResult.Value && it.value) { - navigator.navigate(ConnectDestination) { - launchSingleTop = true - popUpTo(NavGraphs.root) { inclusive = true } - } - } - } - - playPaymentResultRecipient.onNavResult { - when (it) { - NavResult.Canceled -> { - /* Do nothing */ - } - is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) - } - } val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -122,15 +102,12 @@ fun OutOfTime( OutOfTimeScreen( state = state, snackbarHostState = snackbarHostState, - onSitePaymentClick = vm::onSitePaymentClick, - onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) }, onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) }, - onDisconnectClick = vm::onDisconnectClick, - onPurchaseBillingProductClick = - dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) }, - navigateToVerificationPendingDialog = + onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, + onPlayPaymentInfoClick = dropUnlessResumed { navigator.navigate(VerificationPendingDestination) }, + onDisconnectClick = vm::onDisconnectClick, ) } @@ -139,14 +116,11 @@ fun OutOfTimeScreen( state: OutOfTimeUiState, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onDisconnectClick: () -> Unit, - onSitePaymentClick: () -> Unit, - onRedeemVoucherClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, - onPurchaseBillingProductClick: (ProductId) -> Unit, - navigateToVerificationPendingDialog: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, ) { - val scrollState = rememberScrollState() ScaffoldWithTopBarAndDeviceName( snackbarHostState = snackbarHostState, @@ -167,6 +141,15 @@ fun OutOfTimeScreen( deviceName = state.deviceName, timeLeft = null, ) { + var addTimeBottomSheetState by remember { mutableStateOf(false) } + if (!LocalInspectionMode.current) { + AddTimeBottomSheet( + visible = addTimeBottomSheetState, + onHideBottomSheet = { addTimeBottomSheetState = false }, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + ) + } Column( modifier = Modifier.fillMaxSize() @@ -184,57 +167,55 @@ fun OutOfTimeScreen( ) .background(color = MaterialTheme.colorScheme.surface) ) { - Image( - painter = painterResource(id = R.drawable.icon_fail), - contentDescription = null, - modifier = - Modifier.align(Alignment.CenterHorizontally) - .padding(bottom = Dimens.mediumSpacer), - ) - Text( - text = stringResource(id = R.string.out_of_time), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.testTag(OUT_OF_TIME_SCREEN_TITLE_TEST_TAG), - ) - Text( - text = - buildString { - append(stringResource(R.string.account_credit_has_expired)) - if (state.showSitePayment) { - append(" ") - append(stringResource(R.string.add_time_to_account)) - } - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = Dimens.mediumPadding), - ) + Content(showSitePayment = state.showSitePayment) Spacer(modifier = Modifier.weight(1f).defaultMinSize(minHeight = Dimens.verticalSpace)) // Button area - ButtonPanel( state = state, onDisconnectClick = onDisconnectClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - onRedeemVoucherClick = onRedeemVoucherClick, - onSitePaymentClick = onSitePaymentClick, - navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, + onAddMoreTimeClick = { addTimeBottomSheetState = true }, + onInfoClick = onPlayPaymentInfoClick, ) } } } @Composable +private fun ColumnScope.Content(showSitePayment: Boolean) { + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, + modifier = + Modifier.align(Alignment.CenterHorizontally).padding(bottom = Dimens.mediumSpacer), + ) + Text( + text = stringResource(id = R.string.out_of_time), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.testTag(OUT_OF_TIME_SCREEN_TITLE_TEST_TAG), + ) + Text( + text = + buildString { + append(stringResource(R.string.account_credit_has_expired)) + if (showSitePayment) { + append(" ") + append(stringResource(R.string.add_time_to_account)) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = Dimens.mediumPadding), + ) +} + +@Composable private fun ButtonPanel( state: OutOfTimeUiState, onDisconnectClick: () -> Unit, - onPurchaseBillingProductClick: (ProductId) -> Unit, - onRedeemVoucherClick: () -> Unit, - onSitePaymentClick: () -> Unit, - navigateToVerificationPendingDialog: () -> Unit, + onAddMoreTimeClick: () -> Unit, + onInfoClick: () -> Unit, ) { - Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { if (state.tunnelState.isSecured()) { NegativeButton( @@ -242,29 +223,25 @@ private fun ButtonPanel( text = stringResource(id = R.string.disconnect), ) } - state.billingPaymentState?.let { - PlayPayment( - billingPaymentState = state.billingPaymentState, - onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) - }, - onInfoClick = navigateToVerificationPendingDialog, - ) - } - if (state.showSitePayment) { - SitePaymentButton( - onClick = onSitePaymentClick, - isEnabled = state.tunnelState.enableSitePaymentButton(), - ) + if (state.verificationPending) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onInfoClick, + modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.payment_status_pending_short), + ) + } } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, - isEnabled = state.tunnelState.enableRedeemButton(), - ) + VariantButton(onClick = onAddMoreTimeClick, text = stringResource(id = R.string.add_time)) } } - -private fun TunnelState.enableSitePaymentButton(): Boolean = this is TunnelState.Disconnected - -private fun TunnelState.enableRedeemButton(): Boolean = - !(this is TunnelState.Error && this.errorState.cause is ErrorStateCause.IsOffline) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt index 482eafe863..3cd23d447d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt @@ -20,11 +20,15 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -42,92 +46,59 @@ import com.ramcosta.composedestinations.generated.NavGraphs import com.ramcosta.composedestinations.generated.destinations.AccountDestination import com.ramcosta.composedestinations.generated.destinations.ConnectDestination import com.ramcosta.composedestinations.generated.destinations.DeviceNameInfoDestination -import com.ramcosta.composedestinations.generated.destinations.PaymentDestination import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination import com.ramcosta.composedestinations.generated.destinations.SettingsDestination import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.result.NavResult -import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.NegativeButton -import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton -import net.mullvad.mullvadvpn.compose.button.SitePaymentButton +import net.mullvad.mullvadvpn.compose.button.VariantButton +import net.mullvad.mullvadvpn.compose.component.AddTimeBottomSheet import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton -import net.mullvad.mullvadvpn.compose.component.PlayPayment +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook -import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.WelcomeScreenUiStatePreviewParameterProvider -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.compose.transitions.HomeTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.lib.ui.tag.PLAY_PAYMENT_INFO_ICON_TEST_TAG +import net.mullvad.mullvadvpn.util.Lc import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.koin.androidx.compose.koinViewModel -@Preview +@Preview("Loading|Content|TunnelConnected") @Composable private fun PreviewWelcomeScreen( - @PreviewParameter(WelcomeScreenUiStatePreviewParameterProvider::class) state: WelcomeUiState + @PreviewParameter(WelcomeScreenUiStatePreviewParameterProvider::class) + state: Lc<Unit, WelcomeUiState> ) { AppTheme { WelcomeScreen( state = state, - onSitePaymentClick = {}, - onRedeemVoucherClick = {}, onSettingsClick = {}, onAccountClick = {}, - onPurchaseBillingProductClick = { _ -> }, navigateToDeviceInfoDialog = {}, - navigateToVerificationPendingDialog = {}, onDisconnectClick = {}, + onRedeemVoucherClick = {}, + onPlayPaymentInfoClick = {}, ) } } @Destination<RootGraph>(style = HomeTransition::class) @Composable -fun Welcome( - navigator: DestinationsNavigator, - voucherRedeemResultRecipient: ResultRecipient<RedeemVoucherDestination, Boolean>, - playPaymentResultRecipient: ResultRecipient<PaymentDestination, Boolean>, -) { +fun Welcome(navigator: DestinationsNavigator) { val vm = koinViewModel<WelcomeViewModel>() val state by vm.uiState.collectAsStateWithLifecycle() - voucherRedeemResultRecipient.onNavResult { - when (it) { - NavResult.Canceled -> { - /* Do nothing */ - } - is NavResult.Value -> - // If we successfully redeemed a voucher, navigate to Connect screen - if (it.value) { - navigator.navigate(ConnectDestination) { - popUpTo(NavGraphs.root) { inclusive = true } - } - } - } - } - - playPaymentResultRecipient.onNavResult { - when (it) { - NavResult.Canceled -> { - /* Do nothing */ - } - is NavResult.Value -> vm.onClosePurchaseResultDialog(it.value) - } - } - val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() @@ -151,7 +122,7 @@ fun Welcome( val credentialsManager = CredentialManager.create(context) try { credentialsManager.createCredential(context, createPasswordRequest) - } catch (e: CreateCredentialException) { + } catch (_: CreateCredentialException) { Logger.w("Unable to create Credentials") } } @@ -161,32 +132,27 @@ fun Welcome( WelcomeScreen( state = state, snackbarHostState = snackbarHostState, - onSitePaymentClick = dropUnlessResumed { vm.onSitePaymentClick() }, - onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, onSettingsClick = dropUnlessResumed { navigator.navigate(SettingsDestination) }, onAccountClick = dropUnlessResumed { navigator.navigate(AccountDestination) }, navigateToDeviceInfoDialog = dropUnlessResumed { navigator.navigate(DeviceNameInfoDestination) }, - onPurchaseBillingProductClick = - dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) }, onDisconnectClick = vm::onDisconnectClick, - navigateToVerificationPendingDialog = + onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, + onPlayPaymentInfoClick = dropUnlessResumed { navigator.navigate(VerificationPendingDestination) }, ) } @Composable fun WelcomeScreen( - state: WelcomeUiState, + state: Lc<Unit, WelcomeUiState>, snackbarHostState: SnackbarHostState = SnackbarHostState(), - onSitePaymentClick: () -> Unit, - onRedeemVoucherClick: () -> Unit, onSettingsClick: () -> Unit, onAccountClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit, onDisconnectClick: () -> Unit, + onRedeemVoucherClick: () -> Unit, + onPlayPaymentInfoClick: () -> Unit, navigateToDeviceInfoDialog: () -> Unit, - navigateToVerificationPendingDialog: () -> Unit, ) { val scrollState = rememberScrollState() @@ -197,6 +163,16 @@ fun WelcomeScreen( onAccountClicked = onAccountClick, snackbarHostState = snackbarHostState, ) { + var addTimeBottomSheetState by remember { mutableStateOf(false) } + if (!LocalInspectionMode.current) { + AddTimeBottomSheet( + visible = addTimeBottomSheetState, + onHideBottomSheet = { addTimeBottomSheetState = false }, + onRedeemVoucherClick = onRedeemVoucherClick, + onPlayPaymentInfoClick = onPlayPaymentInfoClick, + ) + } + Column( modifier = Modifier.fillMaxSize() @@ -214,16 +190,15 @@ fun WelcomeScreen( Spacer(modifier = Modifier.weight(1f)) // Button area - ButtonPanel( - showDisconnectButton = state.tunnelState.isSecured(), - showSitePayment = state.showSitePayment, - billingPaymentState = state.billingPaymentState, - onSitePaymentClick = onSitePaymentClick, - onRedeemVoucherClick = onRedeemVoucherClick, - onPurchaseBillingProductClick = onPurchaseBillingProductClick, - onPaymentInfoClick = navigateToVerificationPendingDialog, - onDisconnectClick = onDisconnectClick, - ) + if (state is Lc.Content) { + ButtonPanel( + showDisconnectButton = state.value.tunnelState.isSecured(), + verificationPending = state.value.verificationPending, + onAddMoreTimeClick = { addTimeBottomSheetState = true }, + onDisconnectClick = onDisconnectClick, + onInfoClick = onPlayPaymentInfoClick, + ) + } } } } @@ -231,7 +206,7 @@ fun WelcomeScreen( @Composable private fun WelcomeInfo( snackbarHostState: SnackbarHostState, - state: WelcomeUiState, + state: Lc<Unit, WelcomeUiState>, navigateToDeviceInfoDialog: () -> Unit, ) { Column { @@ -258,15 +233,28 @@ private fun WelcomeInfo( color = MaterialTheme.colorScheme.onSurface, ) - AccountNumberRow(snackbarHostState, state) + when (state) { + is Lc.Loading -> + MullvadCircularProgressIndicatorMedium( + modifier = + Modifier.padding( + horizontal = Dimens.sideMargin, + vertical = Dimens.smallPadding, + ) + ) + is Lc.Content -> { + // Account number + AccountNumberRow(snackbarHostState, state.value) - DeviceNameRow(deviceName = state.deviceName, navigateToDeviceInfoDialog) + DeviceNameRow(deviceName = state.value.deviceName, navigateToDeviceInfoDialog) + } + } Text( text = buildString { append(stringResource(id = R.string.pay_to_start_using)) - if (state.showSitePayment) { + if (state.contentOrNull()?.showSitePayment == true) { append(" ") append(stringResource(id = R.string.add_time_to_account)) } @@ -348,65 +336,51 @@ fun DeviceNameRow(deviceName: String?, navigateToDeviceInfoDialog: () -> Unit) { @Composable private fun ButtonPanel( showDisconnectButton: Boolean, - showSitePayment: Boolean, - billingPaymentState: PaymentState?, - onSitePaymentClick: () -> Unit, - onRedeemVoucherClick: () -> Unit, - onPurchaseBillingProductClick: (productId: ProductId) -> Unit, - onPaymentInfoClick: () -> Unit, + verificationPending: Boolean, + onAddMoreTimeClick: () -> Unit, onDisconnectClick: () -> Unit, + onInfoClick: () -> Unit, ) { - Column(modifier = Modifier.fillMaxWidth().padding(top = Dimens.mediumPadding)) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding( + top = Dimens.mediumPadding, + start = Dimens.sideMargin, + end = Dimens.sideMargin, + ) + ) { Spacer(modifier = Modifier.padding(top = Dimens.screenTopMargin)) if (showDisconnectButton) { NegativeButton( onClick = onDisconnectClick, text = stringResource(id = R.string.disconnect), - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing, - ), - ) - } - billingPaymentState?.let { - PlayPayment( - billingPaymentState = billingPaymentState, - onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) - }, - onInfoClick = onPaymentInfoClick, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing, - ) - .align(Alignment.CenterHorizontally), + modifier = Modifier.padding(bottom = Dimens.buttonSpacing), ) } - if (showSitePayment) { - SitePaymentButton( - onClick = onSitePaymentClick, - isEnabled = true, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing, - ), - ) + if (verificationPending) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton( + onClick = onInfoClick, + modifier = Modifier.testTag(PLAY_PAYMENT_INFO_ICON_TEST_TAG), + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + text = stringResource(R.string.payment_status_pending_short), + ) + } } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, - isEnabled = true, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.screenBottomMargin, - ), + VariantButton( + onClick = onAddMoreTimeClick, + text = stringResource(id = R.string.add_time), + modifier = Modifier.padding(bottom = Dimens.buttonSpacing), ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AddTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AddTimeUiState.kt new file mode 100644 index 0000000000..a9b4d3c09c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/AddTimeUiState.kt @@ -0,0 +1,26 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.payment.model.ProductId + +data class AddTimeUiState( + val purchaseState: PurchaseState?, + val billingPaymentState: PaymentState?, + val showSitePayment: Boolean, + val tunnelStateBlocked: Boolean, +) + +sealed interface PurchaseState { + data object Connecting : PurchaseState + + data object VerificationStarted : PurchaseState + + data object VerifyingPurchase : PurchaseState + + data class Success(val productId: ProductId) : PurchaseState + + sealed interface Error : PurchaseState { + data class TransactionIdError(val productId: ProductId) : Error + + data class OtherError(val productId: ProductId) : Error + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt index 6e195d40d8..89708d95eb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt @@ -6,5 +6,5 @@ data class OutOfTimeUiState( val tunnelState: TunnelState = TunnelState.Disconnected(), val deviceName: String = "", val showSitePayment: Boolean = false, - val billingPaymentState: PaymentState? = null, + val verificationPending: Boolean = false, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt index dd9a57626c..880c2b9dcf 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/WelcomeUiState.kt @@ -4,9 +4,9 @@ import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.TunnelState data class WelcomeUiState( - val tunnelState: TunnelState = TunnelState.Disconnected(), - val accountNumber: AccountNumber? = null, - val deviceName: String? = null, - val showSitePayment: Boolean = false, - val billingPaymentState: PaymentState? = null, + val tunnelState: TunnelState, + val accountNumber: AccountNumber?, + val deviceName: String?, + val showSitePayment: Boolean, + val verificationPending: Boolean, ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index cd071746c7..7e798a69c8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt @@ -63,6 +63,7 @@ import net.mullvad.mullvadvpn.usecase.customlists.FilterCustomListsRelayItemUseC import net.mullvad.mullvadvpn.util.ChangelogDataProvider import net.mullvad.mullvadvpn.util.IChangelogDataProvider import net.mullvad.mullvadvpn.viewmodel.AccountViewModel +import net.mullvad.mullvadvpn.viewmodel.AddTimeViewModel import net.mullvad.mullvadvpn.viewmodel.ApiAccessListViewModel import net.mullvad.mullvadvpn.viewmodel.ApiAccessMethodDetailsViewModel import net.mullvad.mullvadvpn.viewmodel.AppInfoViewModel @@ -87,7 +88,6 @@ import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel -import net.mullvad.mullvadvpn.viewmodel.PaymentViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.ReportProblemViewModel import net.mullvad.mullvadvpn.viewmodel.ResetServerIpOverridesConfirmationViewModel @@ -193,7 +193,7 @@ val uiModule = module { single { AppVersionInfoRepository(get(), get()) } // View models - viewModel { AccountViewModel(get(), get(), get(), IS_PLAY_BUILD) } + viewModel { AccountViewModel(get(), get(), get()) } viewModel { ChangelogViewModel(get(), get(), get()) } viewModel { AppInfoViewModel( @@ -241,7 +241,6 @@ val uiModule = module { viewModel { ReportProblemViewModel(get(), get()) } viewModel { ViewLogsViewModel(get()) } viewModel { OutOfTimeViewModel(get(), get(), get(), get(), get(), isPlayBuild = IS_PLAY_BUILD) } - viewModel { PaymentViewModel(get()) } viewModel { FilterViewModel(get(), get()) } viewModel { CreateCustomListDialogViewModel(get(), get()) } viewModel { CustomListLocationsViewModel(get(), get(), get(), get()) } @@ -279,6 +278,14 @@ val uiModule = module { SelectLocationListViewModel(relayListType, get(), get(), get(), get(), get(), get(), get()) } viewModel { DaitaViewModel(get(), get()) } + viewModel { + AddTimeViewModel( + paymentUseCase = get(), + accountRepository = get(), + connectionProxy = get(), + isPlayBuild = IS_PLAY_BUILD, + ) + } // This view model must be single so we correctly attach lifecycle and share it with activity single { MullvadAppViewModel(get(), get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt index c1abfb5717..2e18510178 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/usecase/PaymentUseCase.kt @@ -5,10 +5,12 @@ import arrow.core.Either import arrow.core.right import arrow.resilience.Schedule import arrow.resilience.retryEither +import co.touchlab.kermit.Logger import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import net.mullvad.mullvadvpn.constant.VERIFICATION_BACK_OFF_FACTOR import net.mullvad.mullvadvpn.constant.VERIFICATION_INITIAL_BACK_OFF_DURATION @@ -50,12 +52,16 @@ class PlayPaymentUseCase(private val paymentRepository: PaymentRepository) : Pay delay(EXTRA_LOADING_DELAY_MS) } } + .onEach { Logger.d("Purchase state: ${it::class.simpleName}") } .collect(_purchaseResult) } @Suppress("ensure every public functions method is named 'invoke' with operator modifier") override suspend fun queryPaymentAvailability() { - paymentRepository.queryPaymentAvailability().collect(_paymentAvailability) + paymentRepository + .queryPaymentAvailability() + .onEach { Logger.d("Payment availability: ${it::class.simpleName}") } + .collect(_paymentAvailability) } @Suppress("ensure every public functions method is named 'invoke' with operator modifier") diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt index aa75c2403a..2b9847dd79 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PaymentAvailabilityExtensions.kt @@ -1,6 +1,7 @@ package net.mullvad.mullvadvpn.util import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PaymentState.PaymentAvailable import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability fun PaymentAvailability.toPaymentState(): PaymentState = @@ -17,3 +18,12 @@ fun PaymentAvailability.toPaymentState(): PaymentState = PaymentAvailability.Error.FeatureNotSupported, PaymentAvailability.Error.ItemUnavailable -> PaymentState.NoPayment } + +fun PaymentAvailability?.hasPendingPayment(): Boolean { + return this?.let { paymentAvailability -> + when (val paymentState = paymentAvailability.toPaymentState()) { + is PaymentAvailable -> paymentState.products.any { it.status != null } + else -> false + } + } == true +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt deleted file mode 100644 index 216a995cce..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/PurchaseResultExtensions.kt +++ /dev/null @@ -1,78 +0,0 @@ -package net.mullvad.mullvadvpn.util - -import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogAction -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogIcon -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult - -fun PurchaseResult.toPaymentDialogData(): PaymentDialogData? = - when (this) { - // Idle states - PurchaseResult.Completed.Cancelled, - PurchaseResult.BillingFlowStarted, - is PurchaseResult.Error.BillingError -> { - // Show nothing - null - } - // Fetching products and obfuscated id loading state - PurchaseResult.FetchingProducts, - PurchaseResult.FetchingObfuscationId -> - PaymentDialogData( - title = R.string.connecting, - icon = PaymentDialogIcon.LOADING, - closeOnDismiss = false, - ) - // Verifying loading states - PurchaseResult.VerificationStarted -> - PaymentDialogData( - title = R.string.loading_verifying, - icon = PaymentDialogIcon.LOADING, - closeOnDismiss = false, - ) - // Pending state - PurchaseResult.Completed.Pending, - is PurchaseResult.Error.VerificationError -> - PaymentDialogData( - title = R.string.verifying_purchase, - message = R.string.payment_pending_dialog_message, - confirmAction = PaymentDialogAction.Close, - ) - // Success state - PurchaseResult.Completed.Success -> - PaymentDialogData( - title = R.string.payment_completed_dialog_title, - message = R.string.payment_completed_dialog_message, - icon = PaymentDialogIcon.SUCCESS, - confirmAction = PaymentDialogAction.Close, - successfulPayment = true, - ) - // Error states - is PurchaseResult.Error.TransactionIdError -> - PaymentDialogData( - title = R.string.payment_obfuscation_id_error_dialog_title, - message = R.string.payment_obfuscation_id_error_dialog_message, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - dismissAction = PaymentDialogAction.RetryPurchase(productId = this.productId), - ) - is PurchaseResult.Error.FetchProductsError, - is PurchaseResult.Error.NoProductFound -> { - PaymentDialogData( - title = R.string.payment_billing_error_dialog_title, - message = R.string.payment_billing_error_dialog_message, - icon = PaymentDialogIcon.FAIL, - confirmAction = PaymentDialogAction.Close, - dismissAction = - PaymentDialogAction.RetryPurchase( - productId = - when (this) { - is PurchaseResult.Error.FetchProductsError -> this.productId - is PurchaseResult.Error.NoProductFound -> this.productId - else -> ProductId("") - } - ), - ) - } - } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt index 8dd4253553..08a99a8aef 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModel.kt @@ -1,6 +1,5 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import java.time.ZonedDateTime @@ -17,54 +16,49 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken -import net.mullvad.mullvadvpn.lib.payment.model.ProductId import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.hasPendingPayment import net.mullvad.mullvadvpn.util.isSuccess -import net.mullvad.mullvadvpn.util.toPaymentState +import net.mullvad.mullvadvpn.util.toLc class AccountViewModel( private val accountRepository: AccountRepository, deviceRepository: DeviceRepository, private val paymentUseCase: PaymentUseCase, - private val isPlayBuild: Boolean, ) : ViewModel() { private val _uiSideEffect = Channel<UiSideEffect>() val uiSideEffect = _uiSideEffect.receiveAsFlow() private val isLoggingOut = MutableStateFlow(false) - private val isLoadingAccountPage = MutableStateFlow(false) - val uiState: StateFlow<AccountUiState> = + val uiState: StateFlow<Lc<Unit, AccountUiState>> = combine( deviceRepository.deviceState.filterIsInstance<DeviceState.LoggedIn>(), accountData(), paymentUseCase.paymentAvailability, isLoggingOut, - isLoadingAccountPage, - ) { deviceState, accountData, paymentAvailability, isLoggingOut, isLoadingAccountPage -> + ) { deviceState, accountData, paymentAvailability, isLoggingOut -> AccountUiState( - deviceName = deviceState.device.displayName(), - accountNumber = deviceState.accountNumber, - accountExpiry = accountData?.expiryDate, - showLogoutLoading = isLoggingOut, - showManageAccountLoading = isLoadingAccountPage, - showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), - ) + deviceName = deviceState.device.displayName(), + accountNumber = deviceState.accountNumber, + accountExpiry = accountData?.expiryDate, + showLogoutLoading = isLoggingOut, + verificationPending = paymentAvailability.hasPendingPayment(), + ) + .toLc<Unit, AccountUiState>() } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), AccountUiState.default()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lc.Loading(Unit)) init { updateAccountExpiry() verifyPurchases() - fetchPaymentAvailability() } private fun accountData(): Flow<AccountData?> = @@ -74,17 +68,6 @@ class AccountViewModel( .onStart<AccountData?> { emit(accountRepository.accountData.value) } .distinctUntilChanged() - fun onManageAccountClick() { - if (isLoadingAccountPage.value) return - isLoadingAccountPage.value = true - - viewModelScope.launch { - val wwwAuthToken = accountRepository.getWebsiteAuthToken() - _uiSideEffect.send(UiSideEffect.OpenAccountManagementPageInBrowser(wwwAuthToken)) - isLoadingAccountPage.value = false - } - } - fun onLogoutClick() { if (isLoggingOut.value) return isLoggingOut.value = true @@ -104,8 +87,8 @@ class AccountViewModel( viewModelScope.launch { _uiSideEffect.send(UiSideEffect.CopyAccountNumber(accountNumber)) } } - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } + private fun updateAccountExpiry() { + viewModelScope.launch { accountRepository.getAccountData() } } private fun verifyPurchases() { @@ -116,30 +99,6 @@ class AccountViewModel( } } - private fun fetchPaymentAvailability() { - viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } - } - - fun onClosePurchaseResultDialog(success: Boolean) { - // We are closing the dialog without any action, this can happen either if an error occurred - // during the purchase or the purchase ended successfully. - // If the payment was successful we want to update the account expiry. If not successful we - // should check payment availability and verify any purchases to handle potential errors. - if (success) { - updateAccountExpiry() - } else { - fetchPaymentAvailability() - verifyPurchases() // Attempt to verify again - } - viewModelScope.launch { - paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. - } - } - - private fun updateAccountExpiry() { - viewModelScope.launch { accountRepository.getAccountData() } - } - sealed class UiSideEffect { data object NavigateToLogin : UiSideEffect() @@ -153,24 +112,9 @@ class AccountViewModel( } data class AccountUiState( - val deviceName: String?, - val accountNumber: AccountNumber?, + val deviceName: String, + val accountNumber: AccountNumber, val accountExpiry: ZonedDateTime?, - val showSitePayment: Boolean, - val billingPaymentState: PaymentState? = null, - val showLogoutLoading: Boolean = false, - val showManageAccountLoading: Boolean = false, -) { - companion object { - fun default() = - AccountUiState( - deviceName = null, - accountNumber = null, - accountExpiry = null, - showLogoutLoading = false, - showManageAccountLoading = false, - showSitePayment = false, - billingPaymentState = PaymentState.Loading, - ) - } -} + val showLogoutLoading: Boolean, + val verificationPending: Boolean, +) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModel.kt new file mode 100644 index 0000000000..c865207353 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModel.kt @@ -0,0 +1,136 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.app.Activity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.isSuccess +import net.mullvad.mullvadvpn.util.toPaymentState +import net.mullvad.mullvadvpn.viewmodel.AddMoreTimeSideEffect.OpenAccountManagementPageInBrowser + +class AddTimeViewModel( + private val paymentUseCase: PaymentUseCase, + private val accountRepository: AccountRepository, + private val connectionProxy: ConnectionProxy, + private val isPlayBuild: Boolean, +) : ViewModel() { + private val _uiSideEffect = Channel<AddMoreTimeSideEffect>() + val uiSideEffect = _uiSideEffect.receiveAsFlow() + + val uiState: StateFlow<Lc<Unit, AddTimeUiState>> = + combine( + paymentUseCase.paymentAvailability, + paymentUseCase.purchaseResult, + connectionProxy.tunnelState, + ) { paymentAvailability, purchaseResult, tunnelState -> + Lc.Content( + AddTimeUiState( + purchaseState = purchaseResult?.toPurchaseState(), + billingPaymentState = paymentAvailability?.toPaymentState(), + tunnelStateBlocked = tunnelState.isBlocked(), + showSitePayment = !isPlayBuild, + ) + ) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = Lc.Loading(Unit), + ) + + init { + verifyPurchases() + fetchPaymentAvailability() + } + + fun onManageAccountClick() { + viewModelScope.launch { + val wwwAuthToken = accountRepository.getWebsiteAuthToken() + _uiSideEffect.send(OpenAccountManagementPageInBrowser(wwwAuthToken)) + } + } + + fun fetchPaymentAvailability() { + viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } + } + + fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { + viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } + } + + fun onClosePurchaseResultDialog(success: Boolean) { + // We are closing the dialog without any action, this can happen either if an error occurred + // during the purchase or the purchase ended successfully. + // If the payment was successful we want to update the account expiry. If not successful we + // should check payment availability and verify any purchases to handle potential errors. + if (success) { + updateAccountExpiry() + } else { + fetchPaymentAvailability() + verifyPurchases() // Attempt to verify again + } + viewModelScope.launch { + paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. + } + } + + private fun verifyPurchases() { + viewModelScope.launch { + if (paymentUseCase.verifyPurchases().isSuccess()) { + updateAccountExpiry() + } + } + } + + private fun updateAccountExpiry() { + viewModelScope.launch { accountRepository.getAccountData() } + } + + private fun PurchaseResult.toPurchaseState() = + when (this) { + // Idle states + PurchaseResult.Completed.Cancelled, + PurchaseResult.BillingFlowStarted, + is PurchaseResult.Error.BillingError -> { + // Show nothing + null + } + // Fetching products and obfuscated id loading state + PurchaseResult.FetchingProducts, + PurchaseResult.FetchingObfuscationId -> PurchaseState.Connecting + // Verifying loading states + PurchaseResult.VerificationStarted -> PurchaseState.VerificationStarted + // Pending state + is PurchaseResult.Completed.Pending, + is PurchaseResult.Error.VerificationError -> PurchaseState.VerifyingPurchase + // Success state + is PurchaseResult.Completed.Success -> PurchaseState.Success(productId) + // Error states + is PurchaseResult.Error.TransactionIdError -> + PurchaseState.Error.TransactionIdError(productId = productId) + is PurchaseResult.Error.FetchProductsError -> + PurchaseState.Error.OtherError(productId = productId) + is PurchaseResult.Error.NoProductFound -> + PurchaseState.Error.OtherError(productId = productId) + } +} + +sealed class AddMoreTimeSideEffect { + data class OpenAccountManagementPageInBrowser(val token: WebsiteAuthToken?) : + AddMoreTimeSideEffect() +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt index 0f8dd8185a..ace068304d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt @@ -20,8 +20,8 @@ import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_POLL_INTERVAL import net.mullvad.mullvadvpn.usecase.OutOfTimeUseCase import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.hasPendingPayment import net.mullvad.mullvadvpn.util.isSuccess -import net.mullvad.mullvadvpn.util.toPaymentState class OutOfTimeViewModel( private val accountRepository: AccountRepository, @@ -46,7 +46,7 @@ class OutOfTimeViewModel( tunnelState = tunnelState, deviceName = deviceState?.displayName() ?: "", showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), + verificationPending = paymentAvailability.hasPendingPayment(), ) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), OutOfTimeUiState()) @@ -59,7 +59,6 @@ class OutOfTimeViewModel( } } verifyPurchases() - fetchPaymentAvailability() viewModelScope.launch { deviceRepository.updateDevice() } } @@ -84,26 +83,6 @@ class OutOfTimeViewModel( } } - private fun fetchPaymentAvailability() { - viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } - } - - fun onClosePurchaseResultDialog(success: Boolean) { - // We are closing the dialog without any action, this can happen either if an error occurred - // during the purchase or the purchase ended successfully. - // If the payment was successful we want to update the account expiry. If not successful we - // should check payment availability and verify any purchases to handle potential errors. - if (success) { - viewModelScope.launch { updateAccountExpiry() } - } else { - fetchPaymentAvailability() - verifyPurchases() // Attempt to verify again - } - viewModelScope.launch { - paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. - } - } - private suspend fun updateAccountExpiry() { accountRepository.getAccountData() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt deleted file mode 100644 index 5deae9534d..0000000000 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModel.kt +++ /dev/null @@ -1,44 +0,0 @@ -package net.mullvad.mullvadvpn.viewmodel - -import android.app.Activity -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData -import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult -import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData - -class PaymentViewModel(private val paymentUseCase: PaymentUseCase) : ViewModel() { - val uiState: StateFlow<PaymentUiState> = - paymentUseCase.purchaseResult - .filterNot { - it is PurchaseResult.Completed.Cancelled || it is PurchaseResult.Error.BillingError - } - .map { PaymentUiState(it?.toPaymentDialogData()) } - .stateIn(viewModelScope, SharingStarted.Lazily, PaymentUiState(PaymentDialogData())) - - val uiSideEffect = - paymentUseCase.purchaseResult - .filter { - it is PurchaseResult.Completed.Cancelled || it is PurchaseResult.Error.BillingError - } - .map { PaymentUiSideEffect.PaymentCancelled } - - fun startBillingPayment(productId: ProductId, activityProvider: () -> Activity) { - viewModelScope.launch { paymentUseCase.purchaseProduct(productId, activityProvider) } - } -} - -data class PaymentUiState(val paymentDialogData: PaymentDialogData?) - -sealed interface PaymentUiSideEffect { - data object PaymentCancelled : PaymentUiSideEffect -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt index 7e41c05286..dcda3ee917 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModel.kt @@ -24,8 +24,9 @@ import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_POLL_INTERVAL import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import net.mullvad.mullvadvpn.util.hasPendingPayment import net.mullvad.mullvadvpn.util.isSuccess -import net.mullvad.mullvadvpn.util.toPaymentState class WelcomeViewModel( private val accountRepository: AccountRepository, @@ -44,15 +45,17 @@ class WelcomeViewModel( deviceRepository.deviceState.filterNotNull(), paymentUseCase.paymentAvailability, ) { tunnelState, accountState, paymentAvailability -> - WelcomeUiState( - tunnelState = tunnelState, - accountNumber = accountState.accountNumber(), - deviceName = accountState.displayName(), - showSitePayment = !isPlayBuild, - billingPaymentState = paymentAvailability?.toPaymentState(), + Lc.Content( + WelcomeUiState( + tunnelState = tunnelState, + accountNumber = accountState.accountNumber(), + deviceName = accountState.displayName(), + showSitePayment = !isPlayBuild, + verificationPending = paymentAvailability.hasPendingPayment(), + ) ) } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), WelcomeUiState()) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lc.Loading(Unit)) init { viewModelScope.launch { @@ -62,10 +65,10 @@ class WelcomeViewModel( } } verifyPurchases() - fetchPaymentAvailability() viewModelScope.launch { deviceRepository.updateDevice() } viewModelScope.launch { - val accountNumber = uiState.map { it.accountNumber }.filterNotNull().first() + val accountNumber = + uiState.map { it.contentOrNull()?.accountNumber }.filterNotNull().first() _uiSideEffect.send(UiSideEffect.StoreCredentialsRequest(accountNumber)) } } @@ -98,27 +101,6 @@ class WelcomeViewModel( } } - private fun fetchPaymentAvailability() { - viewModelScope.launch { paymentUseCase.queryPaymentAvailability() } - } - - fun onClosePurchaseResultDialog(success: Boolean) { - // We are closing the dialog without any action, this can happen either if an error occurred - // during the purchase or the purchase ended successfully. - // If the payment was successful we want to update the account expiry. If not successful we - // should check payment availability and verify any purchases to handle potential errors. - if (success) { - viewModelScope.launch { updateAccountExpiry() } - // Emission of out of time navigation is handled by launch in onStart - } else { - fetchPaymentAvailability() - verifyPurchases() // Attempt to verify again - } - viewModelScope.launch { - paymentUseCase.resetPurchaseResult() // So that we do not show the dialog again. - } - } - private suspend fun updateAccountExpiry() { accountRepository.getAccountData() } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt index a07fefabe3..c855b9a473 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/usecase/PlayPaymentUseCaseTest.kt @@ -78,7 +78,7 @@ class PlayPaymentUseCaseTest { @Test fun `resetPurchaseResult call should result in purchaseResult null`() = runTest { // Arrange - val completedSuccess = PurchaseResult.Completed.Success + val completedSuccess = PurchaseResult.Completed.Success(ProductId("one_month")) val productId = ProductId("productId") val paymentRepositoryPurchaseResultFlow = flow { emit(completedSuccess) } every { mockPaymentRepository.purchaseProduct(any(), any()) } returns diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt index 74a6bbd414..7a6b756bf5 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AccountViewModelTest.kt @@ -1,34 +1,32 @@ package net.mullvad.mullvadvpn.viewmodel -import android.app.Activity import app.cash.turbine.test import arrow.core.right import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import java.time.ZonedDateTime import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.PaymentState import net.mullvad.mullvadvpn.data.UUID import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.Device import net.mullvad.mullvadvpn.lib.model.DeviceId import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus import net.mullvad.mullvadvpn.lib.payment.model.ProductId -import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -54,17 +52,14 @@ class AccountViewModelTest { DeviceState.LoggedIn(accountNumber = dummyAccountNumber, device = dummyDevice) ) private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null) - private val purchaseResult = MutableStateFlow<PurchaseResult?>(null) private val accountExpiryState = MutableStateFlow(null) private lateinit var viewModel: AccountViewModel @BeforeEach fun setup() { - mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) every { mockAccountRepository.accountData } returns accountExpiryState every { mockDeviceRepository.deviceState } returns deviceState - coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult coEvery { mockPaymentUseCase.paymentAvailability } returns paymentAvailability coEvery { mockAccountRepository.getAccountData() } returns null @@ -73,7 +68,6 @@ class AccountViewModelTest { accountRepository = mockAccountRepository, deviceRepository = mockDeviceRepository, paymentUseCase = mockPaymentUseCase, - isPlayBuild = false, ) } @@ -89,7 +83,8 @@ class AccountViewModelTest { deviceState.value = DeviceState.LoggedIn(accountNumber = dummyAccountNumber, device = dummyDevice) val result = awaitItem() - assertEquals(dummyAccountNumber, result.accountNumber) + assertIs<Lc.Content<AccountUiState>>(result) + assertEquals(dummyAccountNumber, result.value.accountNumber) } } @@ -106,119 +101,29 @@ class AccountViewModelTest { } @Test - fun `when paymentAvailability emits ProductsUnavailable uiState should be NoPayment`() = - runTest { - // Act, Assert - viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit(PaymentAvailability.ProductsUnavailable) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.NoPayment>(result) - } - } + fun `when there is a pending purchase, uiState should reflect it`() = runTest { + // Arrange + paymentAvailability.value = + PaymentAvailability.ProductsAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test_product_id"), + price = ProductPrice("9.99"), + status = PaymentStatus.PENDING, + ) + ) + ) - @Test - fun `when paymentAvailability emits ErrorOther uiState should be ErrorGeneric`() = runTest { // Act, Assert viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit(PaymentAvailability.Error.Other(mockk())) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Generic>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorBillingUnavailable uiState should be ErrorBilling`() = - runTest { - // Act, Assert - viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit(PaymentAvailability.Error.BillingUnavailable) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Billing>(result) - } - } - - @Test - fun `when paymentAvailability emits ProductsAvailable uiState should be Available with products`() = - runTest { - // Arrange - val mockProduct: PaymentProduct = mockk() - val expectedProductList = listOf(mockProduct) - - // Act, Assert - viewModel.uiState.test { - awaitItem() // Default state - paymentAvailability.tryEmit( - PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - ) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.PaymentAvailable>(result) - assertLists(expectedProductList, result.products) - } + val result = awaitItem() + assertIs<Lc.Content<AccountUiState>>(result) + assertEquals(true, result.value.verificationPending) } - - @Test - fun `startBillingPayment should invoke purchaseProduct on PaymentUseCase`() { - // Arrange - val mockProductId = ProductId("MOCK") - val mockActivityProvider = mockk<() -> Activity>() - - // Act - viewModel.startBillingPayment(mockProductId, mockActivityProvider) - - // Assert - coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke fetchAccountExpiry on AccountRepository`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockAccountRepository.getAccountData() } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke resetPurchaseResult on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke queryPaymentAvailability on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.queryPaymentAvailability() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke resetPurchaseResult on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } } companion object { - private const val PURCHASE_RESULT_EXTENSIONS_CLASS = - "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" private const val DUMMY_DEVICE_NAME = "fake_name" } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModelTest.kt new file mode 100644 index 0000000000..3d4eb07905 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/AddTimeViewModelTest.kt @@ -0,0 +1,200 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.app.Activity +import app.cash.turbine.test +import arrow.core.right +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.state.AddTimeUiState +import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.PurchaseState +import net.mullvad.mullvadvpn.lib.common.test.assertLists +import net.mullvad.mullvadvpn.lib.model.TunnelState +import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability +import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult +import net.mullvad.mullvadvpn.lib.payment.model.VerificationResult +import net.mullvad.mullvadvpn.lib.shared.AccountRepository +import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy +import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AddTimeViewModelTest { + + private val mockPaymentUseCase: PaymentUseCase = mockk() + private val mockAccountRepository: AccountRepository = mockk() + private val mockConnectionProxy: ConnectionProxy = mockk() + + private val paymentAvailability = MutableStateFlow<PaymentAvailability?>(null) + private val purchaseResult = MutableStateFlow<PurchaseResult?>(null) + private val tunnelState = MutableStateFlow(TunnelState.Disconnected(null)) + + private lateinit var viewModel: AddTimeViewModel + + @BeforeEach + fun setUp() { + every { mockPaymentUseCase.paymentAvailability } returns paymentAvailability + every { mockPaymentUseCase.purchaseResult } returns purchaseResult + every { mockConnectionProxy.tunnelState } returns tunnelState + + coEvery { mockPaymentUseCase.verifyPurchases() } returns + VerificationResult.NothingToVerify.right() + coEvery { mockPaymentUseCase.queryPaymentAvailability() } just Runs + coEvery { mockPaymentUseCase.resetPurchaseResult() } just Runs + coEvery { mockAccountRepository.getAccountData() } returns null + + viewModel = + AddTimeViewModel( + paymentUseCase = mockPaymentUseCase, + accountRepository = mockAccountRepository, + connectionProxy = mockConnectionProxy, + isPlayBuild = true, + ) + } + + @Test + fun `when paymentAvailability emits ProductsUnavailable uiState should be NoPayment`() = + runTest { + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit(PaymentAvailability.ProductsUnavailable) + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.NoPayment>(result.value.billingPaymentState) + } + } + + @Test + fun `when paymentAvailability emits ErrorOther uiState should be null`() = runTest { + // Arrange + paymentAvailability.tryEmit(PaymentAvailability.Error.Other(mockk())) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.Error.Generic>(result.value.billingPaymentState) + } + } + + @Test + fun `when paymentAvailability emits ErrorBillingUnavailable uiState should be ErrorBilling`() = + runTest { + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit(PaymentAvailability.Error.BillingUnavailable) + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.Error.Billing>(result.value.billingPaymentState) + } + } + + @Test + fun `when paymentAvailability emits ProductsAvailable uiState should be Available with products`() = + runTest { + // Arrange + val mockProduct: PaymentProduct = mockk() + val expectedProductList = listOf(mockProduct) + + // Act, Assert + viewModel.uiState.test { + awaitItem() // Default state + paymentAvailability.tryEmit( + PaymentAvailability.ProductsAvailable(listOf(mockProduct)) + ) + val result = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(result) + assertIs<PaymentState.PaymentAvailable>(result.value.billingPaymentState) + assertLists(expectedProductList, result.value.billingPaymentState.products) + } + } + + @Test + fun `startBillingPayment should invoke purchaseProduct on PaymentUseCase`() { + // Arrange + val mockProductId = ProductId("MOCK") + val mockActivityProvider = mockk<() -> Activity>() + coEvery { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) } just + Runs + + // Act + viewModel.startBillingPayment(mockProductId, mockActivityProvider) + + // Assert + coVerify { mockPaymentUseCase.purchaseProduct(mockProductId, mockActivityProvider) } + } + + @Test + fun `onClosePurchaseResultDialog with success should invoke fetchAccountExpiry on AccountRepository`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = true) + + // Assert + coVerify { mockAccountRepository.getAccountData() } + } + + @Test + fun `onClosePurchaseResultDialog with success should invoke resetPurchaseResult on PaymentUseCase`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = true) + + // Assert + coVerify { mockPaymentUseCase.resetPurchaseResult() } + } + + @Test + fun `onClosePurchaseResultDialog with success false should invoke queryPaymentAvailability on PaymentUseCase`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = false) + + // Assert + coVerify { mockPaymentUseCase.queryPaymentAvailability() } + } + + @Test + fun `onClosePurchaseResultDialog with success false should invoke resetPurchaseResult on PaymentUseCase`() { + // Arrange + + // Act + viewModel.onClosePurchaseResultDialog(success = false) + + // Assert + coVerify { mockPaymentUseCase.resetPurchaseResult() } + } + + @Test + fun `purchaseResult emitting Success should result in success dialog state`() = runTest { + // Arrange + val result = PurchaseState.Success(ProductId("one_month")) + val purchaseResultData = PurchaseResult.Completed.Success(ProductId("one_month")) + + // Act, Assert + viewModel.uiState.test { + awaitItem() + purchaseResult.value = purchaseResultData + val item = awaitItem() + assertIs<Lc.Content<AddTimeUiState>>(item) + assertEquals(result, item.value.purchaseState) + } + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt index bfaed10629..fab79f19c4 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModelTest.kt @@ -7,22 +7,23 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import kotlin.test.assertEquals import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.DeviceState import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy @@ -63,8 +64,6 @@ class OutOfTimeViewModelTest { @BeforeEach fun setup() { - mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) - every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow every { mockConnectionProxy.tunnelState } returns tunnelState @@ -151,108 +150,25 @@ class OutOfTimeViewModelTest { } @Test - fun `when paymentAvailability emits ProductsUnavailable uiState should include state NoPayment`() = - runTest { - // Arrange - val productsUnavailable = PaymentAvailability.ProductsUnavailable - paymentAvailabilityFlow.value = productsUnavailable - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.NoPayment>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorOther uiState should include state ErrorGeneric`() = - runTest { - // Arrange - val paymentAvailabilityError = PaymentAvailability.Error.Other(mockk()) - paymentAvailabilityFlow.value = paymentAvailabilityError - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Generic>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorBillingUnavailable uiState should be ErrorBilling`() = - runTest { - // Arrange - val paymentAvailabilityError = PaymentAvailability.Error.BillingUnavailable - paymentAvailabilityFlow.value = paymentAvailabilityError - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Billing>(result) - } - } - - @Test - fun `when paymentAvailability emits ProductsAvailable uiState should be Available with products`() = - runTest { - // Arrange - val mockProduct: PaymentProduct = mockk() - val expectedProductList = listOf(mockProduct) - val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - paymentAvailabilityFlow.value = productsAvailable - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.PaymentAvailable>(result) - assertLists(expectedProductList, result.products) - } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke getAccountData on AccountRepository`() { - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockAccountRepository.getAccountData() } - } - - @Test - fun `onClosePurchaseResultDialog with success should invoke resetPurchaseResult on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = true) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke queryPaymentAvailability on PaymentUseCase`() { - // Arrange - - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.queryPaymentAvailability() } - } - - @Test - fun `onClosePurchaseResultDialog with success false should invoke resetPurchaseResult on PaymentUseCase`() { + fun `when there is a pending purchase, uiState should reflect it`() = runTest { // Arrange + paymentAvailabilityFlow.value = + PaymentAvailability.ProductsAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test_product_id"), + price = ProductPrice("9.99"), + status = PaymentStatus.PENDING, + ) + ) + ) - // Act - viewModel.onClosePurchaseResultDialog(success = false) - - // Assert - coVerify { mockPaymentUseCase.resetPurchaseResult() } - } - - companion object { - private const val PURCHASE_RESULT_EXTENSIONS_CLASS = - "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" + // Act, Assert + viewModel.uiState.test { + val result = awaitItem() + assertIs<OutOfTimeUiState>(result) + assertEquals(true, result.verificationPending) + } } } diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt deleted file mode 100644 index 49e98c95e6..0000000000 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/PaymentViewModelTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package net.mullvad.mullvadvpn.viewmodel - -import app.cash.turbine.test -import io.mockk.coEvery -import io.mockk.mockk -import io.mockk.unmockkAll -import kotlin.test.assertEquals -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.dialog.payment.PaymentDialogData -import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult -import net.mullvad.mullvadvpn.usecase.PaymentUseCase -import net.mullvad.mullvadvpn.util.toPaymentDialogData -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith - -@ExtendWith(TestCoroutineRule::class) -class PaymentViewModelTest { - - private val mockPaymentUseCase: PaymentUseCase = mockk(relaxed = true) - - private val purchaseResult = MutableStateFlow<PurchaseResult?>(null) - - private lateinit var viewModel: PaymentViewModel - - @BeforeEach - fun setup() { - coEvery { mockPaymentUseCase.purchaseResult } returns purchaseResult - - viewModel = PaymentViewModel(paymentUseCase = mockPaymentUseCase) - } - - @AfterEach - fun tearDown() { - unmockkAll() - } - - @Test - fun `given PaymentUseCase purchaseResult emits cancelled uiSideEffect should emit PaymentCancelled`() = - runTest { - // Arrange - val result = PurchaseResult.Completed.Cancelled - purchaseResult.value = result - - // Act, Assert - viewModel.uiState.test { - assertEquals(PaymentDialogData(), awaitItem().paymentDialogData) - purchaseResult.value = result - } - - viewModel.uiSideEffect.test { - assertEquals(PaymentUiSideEffect.PaymentCancelled, awaitItem()) - } - } - - @Test - fun `purchaseResult emitting Success should result in success dialog state`() = runTest { - // Arrange - val result = PurchaseResult.Completed.Success - - // Act, Assert - viewModel.uiState.test { - awaitItem() - purchaseResult.value = result - assertEquals(result.toPaymentDialogData(), awaitItem().paymentDialogData) - } - } -} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt index b2b59ba69e..a96d59361a 100644 --- a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/WelcomeViewModelTest.kt @@ -7,7 +7,6 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic import io.mockk.unmockkAll import java.time.ZonedDateTime import kotlin.test.assertEquals @@ -15,9 +14,8 @@ import kotlin.test.assertIs import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import net.mullvad.mullvadvpn.compose.state.PaymentState +import net.mullvad.mullvadvpn.compose.state.WelcomeUiState import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule -import net.mullvad.mullvadvpn.lib.common.test.assertLists import net.mullvad.mullvadvpn.lib.model.AccountData import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.Device @@ -26,6 +24,9 @@ import net.mullvad.mullvadvpn.lib.model.TunnelState import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken import net.mullvad.mullvadvpn.lib.payment.model.PaymentAvailability import net.mullvad.mullvadvpn.lib.payment.model.PaymentProduct +import net.mullvad.mullvadvpn.lib.payment.model.PaymentStatus +import net.mullvad.mullvadvpn.lib.payment.model.ProductId +import net.mullvad.mullvadvpn.lib.payment.model.ProductPrice import net.mullvad.mullvadvpn.lib.payment.model.PurchaseResult import net.mullvad.mullvadvpn.lib.shared.AccountRepository import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy @@ -33,6 +34,7 @@ import net.mullvad.mullvadvpn.lib.shared.DeviceRepository import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState import net.mullvad.mullvadvpn.usecase.PaymentUseCase +import net.mullvad.mullvadvpn.util.Lc import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -63,8 +65,6 @@ class WelcomeViewModelTest { @BeforeEach fun setup() { - mockkStatic(PURCHASE_RESULT_EXTENSIONS_CLASS) - every { mockDeviceRepository.deviceState } returns deviceStateFlow every { mockServiceConnectionManager.connectionState } returns serviceConnectionStateFlow @@ -120,7 +120,8 @@ class WelcomeViewModelTest { awaitItem() tunnelState.emit(tunnelUiStateTestItem) val result = awaitItem() - assertEquals(tunnelUiStateTestItem, result.tunnelState) + assertIs<Lc.Content<WelcomeUiState>>(result) + assertEquals(tunnelUiStateTestItem, result.value.tunnelState) } } @@ -139,7 +140,9 @@ class WelcomeViewModelTest { paymentAvailabilityFlow.value = null deviceStateFlow.value = DeviceState.LoggedIn(accountNumber = expectedAccountNumber, device = device) - assertEquals(expectedAccountNumber, awaitItem().accountNumber) + val result = awaitItem() + assertIs<Lc.Content<WelcomeUiState>>(result) + assertEquals(expectedAccountNumber, result.value.accountNumber) } } @@ -158,66 +161,6 @@ class WelcomeViewModelTest { } @Test - fun `when paymentAvailability emits ProductsUnavailable uiState should include state NoPayment`() = - runTest { - // Arrange - val productsUnavailable = PaymentAvailability.ProductsUnavailable - - // Act, Assert - viewModel.uiState.test { - // Default item - awaitItem() - paymentAvailabilityFlow.tryEmit(productsUnavailable) - val result = awaitItem().billingPaymentState - assertIs<PaymentState.NoPayment>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorOther uiState should include state ErrorGeneric`() = - runTest { - // Arrange - val paymentOtherError = PaymentAvailability.Error.Other(mockk()) - paymentAvailabilityFlow.tryEmit(paymentOtherError) - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Generic>(result) - } - } - - @Test - fun `when paymentAvailability emits ErrorBillingUnavailable uiState should include state ErrorBilling`() = - runTest { // Arrange - val paymentBillingError = PaymentAvailability.Error.BillingUnavailable - paymentAvailabilityFlow.value = paymentBillingError - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.Error.Billing>(result) - } - } - - @Test - fun `when paymentAvailability emits ProductsAvailable uiState should include state Available with products`() = - runTest { - // Arrange - val mockProduct: PaymentProduct = mockk() - val expectedProductList = listOf(mockProduct) - val productsAvailable = PaymentAvailability.ProductsAvailable(listOf(mockProduct)) - paymentAvailabilityFlow.value = productsAvailable - - // Act, Assert - viewModel.uiState.test { - val result = awaitItem().billingPaymentState - assertIs<PaymentState.PaymentAvailable>(result) - assertLists(expectedProductList, result.products) - } - } - - @Test fun `when on disconnect click is called should call connection proxy disconnect`() = runTest { // Arrange coEvery { mockConnectionProxy.disconnect() } returns true.right() @@ -229,8 +172,26 @@ class WelcomeViewModelTest { coVerify { mockConnectionProxy.disconnect() } } - companion object { - private const val PURCHASE_RESULT_EXTENSIONS_CLASS = - "net.mullvad.mullvadvpn.util.PurchaseResultExtensionsKt" + @Test + fun `when there is a pending purchase, uiState should reflect it`() = runTest { + // Arrange + paymentAvailabilityFlow.value = + PaymentAvailability.ProductsAvailable( + products = + listOf( + PaymentProduct( + productId = ProductId("test_product_id"), + price = ProductPrice("9.99"), + status = PaymentStatus.PENDING, + ) + ) + ) + + // Act, Assert + viewModel.uiState.test { + val result = awaitItem() + assertIs<Lc.Content<WelcomeUiState>>(result) + assertEquals(true, result.value.verificationPending) + } } } |
