From 0599b1fa3d703bca872001c80c31424387d4024c Mon Sep 17 00:00:00 2001 From: saber safavi Date: Tue, 26 Sep 2023 16:11:12 +0200 Subject: [PATCH] Migrate voucher dialog to compose screen remove unused codes and resources --- CHANGELOG.md | 1 + .../compose/dialog/RedeemVoucherDialog.kt | 253 ++++++++++++++++++ .../screen/RedeemVoucherDialogScreen.kt | 30 +++ .../constant/AccountExpiryConstant.kt | 3 + .../mullvadvpn/constant/CommonConstant.kt | 3 + .../fragment/RedeemVoucherDialogFragment.kt | 181 ++----------- .../src/main/res/layout/redeem_voucher.xml | 58 ---- 7 files changed, 309 insertions(+), 220 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt delete mode 100644 android/app/src/main/res/layout/redeem_voucher.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index c76f35844a64..ab40ae2ee6e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Line wrap the file at 100 chars. Th - Migrate in app notifications to compose. - Move out of time evaluation to connect view model. - Migrate out of time view to compose. +- Migrate voucher dialog to compose. #### Linux - Don't block forwarding of traffic when the split tunnel mark (ct mark) is set. diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt new file mode 100644 index 000000000000..a83f7fcd4ffb --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt @@ -0,0 +1,253 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.Image +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.width +import androidx.compose.material3.AlertDialog +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState +import net.mullvad.mullvadvpn.compose.textfield.GroupedTextField +import net.mullvad.mullvadvpn.constant.VOUCHER_LENGTH +import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.AlphaInactive +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview(device = Devices.TV_720p) +@Composable +private fun PreviewRedeemVoucherDialog() { + AppTheme { + RedeemVoucherDialog(uiState = VoucherDialogUiState.Default, onRedeem = {}, onDismiss = {}) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialogVerifying() { + AppTheme { + RedeemVoucherDialog(uiState = VoucherDialogUiState.Verifying, onRedeem = {}, onDismiss = {}) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialogError() { + AppTheme { + RedeemVoucherDialog( + uiState = VoucherDialogUiState.Error("An Error message"), + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialogSuccess() { + AppTheme { + RedeemVoucherDialog( + uiState = VoucherDialogUiState.Success("success message"), + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Composable +fun RedeemVoucherDialog( + uiState: VoucherDialogUiState, + onRedeem: (voucherCode: String) -> Unit, + onDismiss: () -> Unit +) { + val voucher = remember { mutableStateOf("") } + + AlertDialog( + title = { + if (uiState !is VoucherDialogUiState.Success) + Text( + text = stringResource(id = R.string.enter_voucher_code), + style = MaterialTheme.typography.titleMedium + ) + }, + confirmButton = { + Column { + if (uiState !is VoucherDialogUiState.Success) { + ActionButton( + text = stringResource(id = R.string.redeem), + onClick = { onRedeem(voucher.value) }, + modifier = Modifier.padding(bottom = Dimens.mediumPadding), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = + MaterialTheme.colorScheme.onSurface + .copy(alpha = AlphaInactive) + .compositeOver(MaterialTheme.colorScheme.surface), + disabledContainerColor = + MaterialTheme.colorScheme.surface + .copy(alpha = AlphaDisabled) + .compositeOver(MaterialTheme.colorScheme.surface) + ), + isEnabled = voucher.value.length == VOUCHER_LENGTH + ) + } + ActionButton( + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + text = + stringResource( + id = + if (uiState is VoucherDialogUiState.Success) + R.string.changes_dialog_dismiss_button + else R.string.cancel + ), + onClick = onDismiss + ) + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (uiState is VoucherDialogUiState.Success) { + Image( + painter = painterResource(R.drawable.icon_success), + contentDescription = null, // No meaningful user info or action. + modifier = Modifier.width(Dimens.buttonHeight).height(Dimens.buttonHeight) + ) + + Text( + text = stringResource(id = R.string.voucher_success_title), + modifier = + Modifier.padding( + start = Dimens.smallPadding, + top = Dimens.screenVerticalMargin + ) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = uiState.message, + modifier = + Modifier.padding( + start = Dimens.smallPadding, + top = Dimens.cellTopPadding + ) + .fillMaxWidth(), + style = MaterialTheme.typography.labelMedium + ) + } else { + GroupedTextField( + value = voucher.value, + onSubmit = { input -> + if (input.isNotEmpty()) { + onRedeem(input) + } + }, + onValueChanged = { input -> voucher.value = input.uppercase() }, + isValidValue = voucher.value.isNotEmpty(), + keyboardType = KeyboardType.Text, + placeholderText = stringResource(id = R.string.voucher_hint), + placeHolderColor = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaDisabled) + .compositeOver(MaterialTheme.colorScheme.primary), + visualTransformation = { voucher -> + vouchersVisualTransformation(voucher, VOUCHER_LENGTH) + }, + maxCharLength = VOUCHER_LENGTH, + onFocusChange = {}, + isDigitsOnlyAllowed = false, + isEnabled = true, + validateRegex = "^[A-Za-z0-9 -]*$".toRegex() + ) + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(Dimens.listIconSize).fillMaxWidth() + ) { + if (uiState is VoucherDialogUiState.Verifying) { + CircularProgressIndicator( + modifier = + Modifier.height(Dimens.loadingSpinnerSizeMedium) + .width(Dimens.loadingSpinnerSizeMedium), + color = MaterialTheme.colorScheme.onSecondary + ) + Text( + text = stringResource(id = R.string.verifying_voucher), + modifier = Modifier.padding(start = Dimens.smallPadding), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.bodySmall + ) + } else if (uiState is VoucherDialogUiState.Error) { + Text( + text = uiState.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + onDismissRequest = onDismiss + ) +} + +const val ACCOUNT_TOKEN_CHUNK_SIZE = 4 + +private fun vouchersVisualTransformation(text: AnnotatedString, maxSize: Int): TransformedText { + val trimmed = + if (text.text.length >= maxSize) text.text.substring(0 until maxSize) else text.text + var out = "" + var transformedMaxSize = maxSize + maxSize / ACCOUNT_TOKEN_CHUNK_SIZE + + for (i in trimmed.indices) { + out += trimmed[i] + if (i % 4 == 3 && i != 15) out += "-" + } + val voucherOffsetTranslator = + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = + (offset + offset / ACCOUNT_TOKEN_CHUNK_SIZE).coerceAtMost(transformedMaxSize - 1) + + override fun transformedToOriginal(offset: Int): Int = + offset - offset / ACCOUNT_TOKEN_CHUNK_SIZE + } + + return TransformedText(AnnotatedString(out), voucherOffsetTranslator) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt new file mode 100644 index 000000000000..e22710464944 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt @@ -0,0 +1,30 @@ +package net.mullvad.mullvadvpn.compose.screen + +import android.content.res.Configuration +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.dialog.RedeemVoucherDialog +import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState +import net.mullvad.mullvadvpn.lib.theme.AppTheme + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialogScreen() { + AppTheme { + RedeemVoucherDialogScreen( + uiState = VoucherDialogUiState.Default, + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Composable +internal fun RedeemVoucherDialogScreen( + uiState: VoucherDialogUiState, + onRedeem: (voucherCode: String) -> Unit, + onDismiss: () -> Unit +) { + RedeemVoucherDialog(uiState, onRedeem, onDismiss) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt index dff48b6228f8..2338a0e54534 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AccountExpiryConstant.kt @@ -1,3 +1,6 @@ package net.mullvad.mullvadvpn.constant const val ACCOUNT_EXPIRY_POLL_INTERVAL: Long = 15 /* s */ * 1000 /* ms */ +const val SECONDS_PER_DAY: Long = 86400 +const val SECONDS_PER_MONTH: Long = 2592000 +const val SECONDS_PER_YEAR: Long = 31104000 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt new file mode 100644 index 000000000000..a01aa08d8b16 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/CommonConstant.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.constant + +const val VOUCHER_LENGTH = 16 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt index 46472ea6cc1e..6963ea46ad43 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/RedeemVoucherDialogFragment.kt @@ -1,187 +1,44 @@ package net.mullvad.mullvadvpn.ui.fragment import android.app.Dialog -import android.content.Context -import android.graphics.drawable.ColorDrawable import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.LayoutParams -import android.widget.EditText -import android.widget.TextView +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.DialogFragment import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.model.VoucherSubmissionError -import net.mullvad.mullvadvpn.model.VoucherSubmissionResult -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.ui.MainActivity -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer -import net.mullvad.mullvadvpn.ui.widget.Button -import net.mullvad.mullvadvpn.util.SegmentedInputFormatter -import org.joda.time.DateTime -import org.koin.android.ext.android.inject - -const val FULL_VOUCHER_CODE_LENGTH = "XXXX-XXXX-XXXX-XXXX".length +import net.mullvad.mullvadvpn.compose.screen.RedeemVoucherDialogScreen +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel class RedeemVoucherDialogFragment : DialogFragment() { - // Injected dependencies - private val accountRepository: AccountRepository by inject() - private val serviceConnectionManager: ServiceConnectionManager by inject() - - private val jobTracker = JobTracker() - - private lateinit var parentActivity: MainActivity - private lateinit var errorMessage: TextView - private lateinit var voucherInput: EditText - - private var accountExpiry: DateTime? = null - private var redeemButton: Button? = null - private var voucherRedeemer: VoucherRedeemer? = null - - private var voucherInputIsValid = false - set(value) { - field = value - updateRedeemButton() - } - - override fun onAttach(context: Context) { - super.onAttach(context) - - parentActivity = context as MainActivity - - serviceConnectionManager.serviceNotifier.subscribe(this) { connection -> - voucherRedeemer = connection?.voucherRedeemer - } - - jobTracker.newUiJob("updateExpiry") { - accountRepository.accountExpiryState.collect { accountExpiry = it.date() } - } - - updateRedeemButton() - } + private val vm by viewModel() + private lateinit var voucherDialog: Dialog override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { - val view = inflater.inflate(R.layout.redeem_voucher, container, false) - - voucherInput = - view.findViewById(R.id.voucher_code).apply { - addTextChangedListener(ValidVoucherCodeChecker()) + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById(R.id.compose_view).setContent { + AppTheme { + RedeemVoucherDialogScreen( + uiState = vm.uiState.collectAsState().value, + onRedeem = { vm.onRedeem(it) }, + onDismiss = { onDismiss(voucherDialog) } + ) + } } - - SegmentedInputFormatter(voucherInput, '-').apply { - allCaps = true - - isValidInputCharacter = { character -> - ('A' <= character && character <= 'Z') || ('0' <= character && character <= '9') - } - } - - redeemButton = - view.findViewById