Skip to content

Commit

Permalink
Add billing payment to welcome screen and view model
Browse files Browse the repository at this point in the history
  • Loading branch information
Pururun committed Sep 20, 2023
1 parent 49ffdb5 commit d96d387
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
Expand All @@ -34,10 +37,17 @@ import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.ActionButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.dialog.PaymentBillingErrorDialog
import net.mullvad.mullvadvpn.compose.dialog.PaymentCompletedDialog
import net.mullvad.mullvadvpn.compose.dialog.PaymentVerificationErrorDialog
import net.mullvad.mullvadvpn.compose.state.AccountDialogState
import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.state.WelcomeDialogState
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
import net.mullvad.mullvadvpn.lib.common.util.SdkUtils
import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces
import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser
import net.mullvad.mullvadvpn.lib.payment.PaymentProduct
import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
Expand All @@ -49,28 +59,36 @@ import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel
private fun PreviewWelcomeScreen() {
AppTheme {
WelcomeScreen(
showSitePayment = true,
uiState = WelcomeUiState(accountNumber = "4444555566667777"),
uiState = WelcomeUiState(accountNumber = "4444555566667777", webPaymentAvailable = true, billingPaymentState = PaymentState.PaymentAvailable(products = listOf(
PaymentProduct("product", "$44")
))),
viewActions = MutableSharedFlow<WelcomeViewModel.ViewAction>().asSharedFlow(),
onSitePaymentClick = {},
onRedeemVoucherClick = {},
onSettingsClick = {},
onAccountClick = {},
openConnectScreen = {}
openConnectScreen = {},
onPurchaseBillingProductClick = {},
onDialogClose = {},
onTryFetchProductsAgain = {},
onTryVerificationAgain = {}
)
}
}

@Composable
fun WelcomeScreen(
showSitePayment: Boolean,
uiState: WelcomeUiState,
viewActions: SharedFlow<WelcomeViewModel.ViewAction>,
onSitePaymentClick: () -> Unit,
onRedeemVoucherClick: () -> Unit,
onSettingsClick: () -> Unit,
onAccountClick: () -> Unit,
openConnectScreen: () -> Unit
openConnectScreen: () -> Unit,
onPurchaseBillingProductClick: (productId: String) -> Unit,
onDialogClose: () -> Unit,
onTryVerificationAgain: () -> Unit,
onTryFetchProductsAgain: () -> Unit
) {
val context = LocalContext.current
LaunchedEffect(key1 = Unit) {
Expand All @@ -82,6 +100,34 @@ fun WelcomeScreen(
}
}
}

when (uiState.dialogState) {
WelcomeDialogState.NoDialog -> {
// Show nothing
}
WelcomeDialogState.PurchaseComplete -> {
PaymentCompletedDialog(onClose = onDialogClose)
}
WelcomeDialogState.BillingError -> {
PaymentBillingErrorDialog(
onTryAgain = {
onDialogClose()
onTryFetchProductsAgain()
},
onClose = onDialogClose
)
}
WelcomeDialogState.VerificationError -> {
PaymentVerificationErrorDialog(
onTryAgain = {
onDialogClose()
onTryVerificationAgain()
},
onClose = onDialogClose
)
}
}

val scrollState = rememberScrollState()
ScaffoldWithTopBar(
topBarColor =
Expand Down Expand Up @@ -167,7 +213,7 @@ fun WelcomeScreen(
text =
buildString {
append(stringResource(id = R.string.pay_to_start_using))
if (showSitePayment) {
if (uiState.webPaymentAvailable) {
append(" ")
append(stringResource(id = R.string.add_time_to_account))
}
Expand All @@ -191,7 +237,51 @@ fun WelcomeScreen(
.background(color = MaterialTheme.colorScheme.background)
) {
Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin))
if (showSitePayment) {
when (uiState.billingPaymentState) {
PaymentState.BillingError,
PaymentState.GenericError -> {
// We show some kind of dialog error at the top
}
PaymentState.Loading -> {
CircularProgressIndicator(
color = MaterialTheme.colorScheme.onBackground,
modifier =
Modifier.padding(
start = Dimens.sideMargin,
end = Dimens.sideMargin,
bottom = Dimens.screenVerticalMargin
)
.size(
width = Dimens.progressIndicatorSize,
height = Dimens.progressIndicatorSize
)
.align(Alignment.CenterHorizontally)
)
}
PaymentState.NoPayment -> {
// Show nothing
}
is PaymentState.PaymentAvailable -> {
uiState.billingPaymentState.products.forEach { product ->
ActionButton(
text = stringResource(id = R.string.add_30_days_time_x, product.price),
onClick = { onPurchaseBillingProductClick(product.productId) },
modifier =
Modifier.padding(
start = Dimens.sideMargin,
end = Dimens.sideMargin,
bottom = Dimens.screenVerticalMargin
),
colors =
ButtonDefaults.buttonColors(
contentColor = MaterialTheme.colorScheme.onPrimary,
containerColor = MaterialTheme.colorScheme.surface
)
)
}
}
}
if (uiState.webPaymentAvailable) {
ActionButton(
onClick = onSitePaymentClick,
modifier =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package net.mullvad.mullvadvpn.compose.state

sealed interface WelcomeDialogState {
data object NoDialog: WelcomeDialogState

data object VerificationError: WelcomeDialogState

data object BillingError: WelcomeDialogState

data object PurchaseComplete: WelcomeDialogState
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@ import net.mullvad.mullvadvpn.model.TunnelState

data class WelcomeUiState(
val tunnelState: TunnelState = TunnelState.Disconnected,
val accountNumber: String? = null
val accountNumber: String? = null,
val webPaymentAvailable: Boolean = false,
val billingPaymentState: PaymentState = PaymentState.Loading,
val dialogState: WelcomeDialogState = WelcomeDialogState.NoDialog
)
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ val uiModule = module {
viewModel { SelectLocationViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { VpnSettingsViewModel(get(), get(), get(), get()) }
viewModel { WelcomeViewModel(get(), get(), get()) }
viewModel { (activity: Activity) ->
WelcomeViewModel(get(), get(), get(), get { parametersOf(activity) })
}
}

const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,13 @@ import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.ui.MainActivity
import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf

class WelcomeFragment : BaseFragment() {

private val vm by viewModel<WelcomeViewModel>()
private val vm by viewModel<WelcomeViewModel> {
parametersOf(requireActivity())
}

override fun onCreateView(
inflater: LayoutInflater,
Expand All @@ -29,14 +32,17 @@ class WelcomeFragment : BaseFragment() {
AppTheme {
val state = vm.uiState.collectAsState().value
WelcomeScreen(
showSitePayment = BuildTypes.RELEASE != BuildConfig.BUILD_TYPE,
uiState = state,
viewActions = vm.viewActions,
onSitePaymentClick = vm::onSitePaymentClick,
onRedeemVoucherClick = ::openRedeemVoucherFragment,
onSettingsClick = ::openSettingsView,
onAccountClick = ::openAccountView,
openConnectScreen = ::advanceToConnectScreen
openConnectScreen = ::advanceToConnectScreen,
onPurchaseBillingProductClick = vm::startBillingPayment,
onDialogClose = vm::closeDialog,
onTryVerificationAgain = vm::verifyPurchases,
onTryFetchProductsAgain = vm::fetchPaymentAvailability
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
Expand All @@ -16,8 +17,14 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.compose.state.PaymentState
import net.mullvad.mullvadvpn.compose.state.WelcomeDialogState
import net.mullvad.mullvadvpn.compose.state.WelcomeUiState
import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL
import net.mullvad.mullvadvpn.lib.payment.BillingPaymentAvailability
import net.mullvad.mullvadvpn.lib.payment.PaymentAvailability
import net.mullvad.mullvadvpn.lib.payment.PaymentRepository
import net.mullvad.mullvadvpn.lib.payment.PurchaseResult
import net.mullvad.mullvadvpn.model.TunnelState
import net.mullvad.mullvadvpn.repository.AccountRepository
import net.mullvad.mullvadvpn.repository.DeviceRepository
Expand All @@ -35,9 +42,12 @@ class WelcomeViewModel(
private val accountRepository: AccountRepository,
private val deviceRepository: DeviceRepository,
private val serviceConnectionManager: ServiceConnectionManager,
private val paymentRepository: PaymentRepository,
private val pollAccountExpiry: Boolean = true
) : ViewModel() {

private val _dialogState = MutableStateFlow<WelcomeDialogState>(WelcomeDialogState.NoDialog)
private val _paymentAvailability = MutableStateFlow<PaymentAvailability?>(null)
private val _viewActions = MutableSharedFlow<ViewAction>(extraBufferCapacity = 1)
val viewActions = _viewActions.asSharedFlow()

Expand All @@ -55,9 +65,19 @@ class WelcomeViewModel(
serviceConnection.connectionProxy.tunnelUiStateFlow(),
deviceRepository.deviceState.debounce {
it.addDebounceForUnknownState(UNKNOWN_STATE_DEBOUNCE_DELAY_MILLISECONDS)
}
) { tunnelState, deviceState ->
WelcomeUiState(tunnelState = tunnelState, accountNumber = deviceState.token())
},
_paymentAvailability,
_dialogState
) { tunnelState, deviceState, paymentAvailability, dialogState ->
WelcomeUiState(
tunnelState = tunnelState,
accountNumber = deviceState.token(),
webPaymentAvailable = paymentAvailability?.webPaymentAvailable ?: false,
billingPaymentState =
paymentAvailability?.billingPaymentAvailability?.toPaymentState()
?: PaymentState.Loading,
dialogState = dialogState
)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), WelcomeUiState())
Expand All @@ -80,6 +100,28 @@ class WelcomeViewModel(
delay(ACCOUNT_EXPIRY_POLL_INTERVAL)
}
}
viewModelScope.launch {
paymentRepository.purchaseResult.collectLatest { result ->
when (result) {
PurchaseResult.PurchaseCancelled -> {
// Do nothing
}
PurchaseResult.PurchaseCompleted -> {
// Show completed dialog
_dialogState.tryEmit(WelcomeDialogState.PurchaseComplete)
}
PurchaseResult.PurchaseError -> {
// Do nothing, errors that we get from here should be shown by google
}
PurchaseResult.VerificationError -> {
// Show verification error
_dialogState.tryEmit(WelcomeDialogState.VerificationError)
}
}
}
}
verifyPurchases()
fetchPaymentAvailability()
}

private fun ConnectionProxy.tunnelUiStateFlow(): Flow<TunnelState> =
Expand All @@ -95,6 +137,43 @@ class WelcomeViewModel(
}
}

fun startBillingPayment(productId: String) {
viewModelScope.launch { paymentRepository.purchaseBillingProduct(productId) }
}

fun closeDialog() {
viewModelScope.launch { _dialogState.tryEmit(WelcomeDialogState.NoDialog) }
}

fun verifyPurchases() {
viewModelScope.launch { paymentRepository.verifyPurchases() }
}

fun fetchPaymentAvailability() {
viewModelScope.launch {
val result = paymentRepository.queryAvailablePaymentTypes()
_paymentAvailability.tryEmit(result)
if (
result.billingPaymentAvailability is
BillingPaymentAvailability.Error.BillingUnavailable ||
result.billingPaymentAvailability is
BillingPaymentAvailability.Error.ServiceUnavailable
) {
_dialogState.tryEmit(WelcomeDialogState.BillingError)
}
}
}

private fun BillingPaymentAvailability.toPaymentState(): PaymentState =
when (this) {
BillingPaymentAvailability.Error.ServiceUnavailable,
BillingPaymentAvailability.Error.BillingUnavailable -> PaymentState.BillingError
is BillingPaymentAvailability.Error.Other -> PaymentState.GenericError
is BillingPaymentAvailability.ProductsAvailable ->
PaymentState.PaymentAvailable(products)
BillingPaymentAvailability.ProductsUnavailable -> PaymentState.NoPayment
}

sealed interface ViewAction {
data class OpenAccountView(val token: String) : ViewAction

Expand Down

0 comments on commit d96d387

Please sign in to comment.