diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-10-11 11:13:07 +0200 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2023-10-11 11:13:07 +0200 |
| commit | 8f35cd09b7a5178acc8d471bec76950d9144a9de (patch) | |
| tree | c6c95d299c477abc4caeb9f383d6e8ee71a8ef62 | |
| parent | 4446d1fda07a35596901d7d3f614b9d033129f24 (diff) | |
| parent | 955fdfdb2cfaeffa7929a2203476c0499d422460 (diff) | |
| download | mullvadvpn-8f35cd09b7a5178acc8d471bec76950d9144a9de.tar.xz mullvadvpn-8f35cd09b7a5178acc8d471bec76950d9144a9de.zip | |
Merge branch 'migrate-voucher-redeem-dialog-to-compose-droid-59'
36 files changed, 672 insertions, 224 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 097410fffb..3c1d7053a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Line wrap the file at 100 chars. Th - Add Social media to content blockers. - Migrate Report Problem view to compose. - Migrate View Logs 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 0000000000..4d6f3f1261 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt @@ -0,0 +1,286 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.foundation.layout.wrapContentSize +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +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.VoucherDialogState +import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState +import net.mullvad.mullvadvpn.compose.textfield.GroupedTextField +import net.mullvad.mullvadvpn.compose.util.vouchersVisualTransformation +import net.mullvad.mullvadvpn.constant.VOUCHER_LENGTH +import net.mullvad.mullvadvpn.lib.theme.AlphaDescription +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 +import org.joda.time.DateTimeConstants + +@Preview(device = Devices.TV_720p) +@Composable +private fun PreviewRedeemVoucherDialog() { + AppTheme { + RedeemVoucherDialog( + uiState = VoucherDialogUiState.INITIAL, + onVoucherInputChange = {}, + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialogVerifying() { + AppTheme { + RedeemVoucherDialog( + uiState = VoucherDialogUiState("", VoucherDialogState.Verifying), + onVoucherInputChange = {}, + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialogError() { + AppTheme { + RedeemVoucherDialog( + uiState = VoucherDialogUiState("", VoucherDialogState.Error("An Error message")), + onVoucherInputChange = {}, + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialogSuccess() { + AppTheme { + RedeemVoucherDialog( + uiState = VoucherDialogUiState("", VoucherDialogState.Success(3600)), + onVoucherInputChange = {}, + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Composable +fun RedeemVoucherDialog( + uiState: VoucherDialogUiState, + onVoucherInputChange: (String) -> Unit = {}, + onRedeem: (voucherCode: String) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + title = { + if (uiState.voucherViewModelState !is VoucherDialogState.Success) + Text( + text = stringResource(id = R.string.enter_voucher_code), + style = MaterialTheme.typography.titleMedium + ) + }, + confirmButton = { + Column { + if (uiState.voucherViewModelState !is VoucherDialogState.Success) { + ActionButton( + text = stringResource(id = R.string.redeem), + onClick = { onRedeem(uiState.voucherInput) }, + 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 = uiState.voucherInput.length == VOUCHER_LENGTH + ) + } + ActionButton( + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + text = + stringResource( + id = + if (uiState.voucherViewModelState is VoucherDialogState.Success) + R.string.changes_dialog_dismiss_button + else R.string.cancel + ), + onClick = onDismiss + ) + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (uiState.voucherViewModelState is VoucherDialogState.Success) { + val days: Int = + (uiState.voucherViewModelState.addedTime / + DateTimeConstants.SECONDS_PER_DAY) + .toInt() + val message = + stringResource( + R.string.added_to_your_account, + when (days) { + 0 -> { + stringResource(R.string.less_than_one_day) + } + in 1..59 -> { + pluralStringResource(id = R.plurals.days, count = days, days) + } + else -> { + pluralStringResource( + id = R.plurals.months, + count = days / 30, + days / 30 + ) + } + } + ) + RedeemSuccessBody(message = message) + } else { + + EnterVoucherBody( + uiState = uiState, + onVoucherInputChange = onVoucherInputChange, + onRedeem = onRedeem + ) + } + } + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + onDismissRequest = onDismiss + ) +} + +@Composable +private fun RedeemSuccessBody(message: String) { + Image( + painter = painterResource(R.drawable.icon_success), + contentDescription = null, + modifier = Modifier.fillMaxWidth().height(Dimens.buttonHeight) + ) + Text( + text = stringResource(id = R.string.voucher_success_title), + modifier = + Modifier.padding( + start = Dimens.smallPadding, + top = Dimens.successIconVerticalPadding, + ) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleMedium + ) + + Text( + text = message, + modifier = + Modifier.padding(start = Dimens.smallPadding, top = Dimens.cellTopPadding) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDescription), + style = MaterialTheme.typography.labelMedium + ) +} + +@Composable +private fun EnterVoucherBody( + uiState: VoucherDialogUiState, + onVoucherInputChange: (String) -> Unit = {}, + onRedeem: (voucherCode: String) -> Unit +) { + val textFieldFocusRequester = FocusRequester() + Box(Modifier.wrapContentSize().clickable { textFieldFocusRequester.requestFocus() }) { + GroupedTextField( + value = uiState.voucherInput, + onSubmit = { input -> + if (uiState.voucherInput.length == VOUCHER_LENGTH) { + onRedeem(input) + } + }, + onValueChanged = { input -> onVoucherInputChange(input.uppercase()) }, + isValidValue = uiState.voucherInput.isNotEmpty(), + keyboardType = KeyboardType.Password, + placeholderText = stringResource(id = R.string.voucher_hint), + placeHolderColor = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaDisabled) + .compositeOver(MaterialTheme.colorScheme.primary), + visualTransformation = vouchersVisualTransformation(), + maxCharLength = VOUCHER_LENGTH, + onFocusChange = {}, + isDigitsOnlyAllowed = false, + isEnabled = true, + modifier = Modifier.focusRequester(textFieldFocusRequester), + validateRegex = "^[A-Za-z0-9]*$".toRegex() + ) + } + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(Dimens.listIconSize).fillMaxWidth() + ) { + if (uiState.voucherViewModelState is VoucherDialogState.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.voucherViewModelState is VoucherDialogState.Error) { + Text( + text = uiState.voucherViewModelState.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } +} 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 0000000000..1344db5b20 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt @@ -0,0 +1,32 @@ +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.INITIAL, + onVoucherInputChange = {}, + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Composable +internal fun RedeemVoucherDialogScreen( + uiState: VoucherDialogUiState, + onVoucherInputChange: (String) -> Unit = {}, + onRedeem: (voucherCode: String) -> Unit, + onDismiss: () -> Unit +) { + RedeemVoucherDialog(uiState, onVoucherInputChange, onRedeem, onDismiss) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt new file mode 100644 index 0000000000..e719bda529 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.state + +data class VoucherDialogUiState( + val voucherInput: String = "", + val voucherViewModelState: VoucherDialogState = VoucherDialogState.Default +) { + companion object { + val INITIAL = VoucherDialogUiState() + } +} + +sealed interface VoucherDialogState { + + data object Default : VoucherDialogState + + data object Verifying : VoucherDialogState + + data class Success(val addedTime: Long) : VoucherDialogState + + data class Error(val errorMessage: String) : VoucherDialogState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt index 13d91df68b..e5350f9844 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/CustomTextField.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -58,7 +59,8 @@ fun CustomTextField( isValidValue: Boolean, isDigitsOnlyAllowed: Boolean, defaultTextColor: Color = Color.White, - textAlign: TextAlign = TextAlign.Start + textAlign: TextAlign = TextAlign.Start, + visualTransformation: VisualTransformation = VisualTransformation.None ) { val fontSize = dimensionResource(id = R.dimen.text_medium_plus).value.sp val shape = RoundedCornerShape(4.dp) @@ -122,6 +124,7 @@ fun CustomTextField( Text( text = placeholderText, color = placeholderTextColor, + style = TextStyle(fontSize = fontSize, textAlign = textAlign), fontSize = fontSize, textAlign = textAlign, modifier = Modifier.fillMaxWidth() @@ -131,6 +134,7 @@ fun CustomTextField( } }, cursorBrush = SolidColor(MullvadBlue), + visualTransformation = visualTransformation, modifier = modifier .background(backgroundColor) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt new file mode 100644 index 0000000000..d9fcecc597 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt @@ -0,0 +1,47 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign + +@Composable +fun GroupedTextField( + value: String, + keyboardType: KeyboardType, + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit, + onFocusChange: (Boolean) -> Unit, + onSubmit: (String) -> Unit, + isEnabled: Boolean = true, + visualTransformation: VisualTransformation, + placeholderText: String = "", + placeHolderColor: Color = MaterialTheme.colorScheme.primary, + maxCharLength: Int = Int.MAX_VALUE, + isValidValue: Boolean, + isDigitsOnlyAllowed: Boolean, + validateRegex: Regex, + defaultTextColor: Color = MaterialTheme.colorScheme.onPrimary, + textAlign: TextAlign = TextAlign.Start +) { + CustomTextField( + value = value, + keyboardType = keyboardType, + onValueChanged = { if (validateRegex.matches(it)) onValueChanged(it) }, + onFocusChange = onFocusChange, + onSubmit = onSubmit, + isValidValue = isValidValue, + isDigitsOnlyAllowed = isDigitsOnlyAllowed, + modifier = modifier, + isEnabled = isEnabled, + visualTransformation = visualTransformation, + placeholderText = placeholderText, + placeHolderColor = placeHolderColor, + maxCharLength = maxCharLength, + defaultTextColor = defaultTextColor, + textAlign = textAlign + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/VoucherVisualTransformation.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/VoucherVisualTransformation.kt new file mode 100644 index 0000000000..c4d5eec0a1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/VoucherVisualTransformation.kt @@ -0,0 +1,38 @@ +package net.mullvad.mullvadvpn.compose.util + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import java.lang.Integer.min + +const val VOUCHER_SEPARATOR = "-" +const val VOUCHER_CHUNK_SIZE = 4 +const val MAX_VOUCHER_LENGTH = 16 + +fun vouchersVisualTransformation() = VisualTransformation { text -> + var out = text.chunked(VOUCHER_CHUNK_SIZE).joinToString(VOUCHER_SEPARATOR) + if ( + text.length % VOUCHER_CHUNK_SIZE == 0 && + text.isNotEmpty() && + text.length < MAX_VOUCHER_LENGTH + ) { + out += VOUCHER_SEPARATOR + } + TransformedText( + AnnotatedString(out), + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + val res = offset + offset / ACCOUNT_TOKEN_CHUNK_SIZE + // Limit max input to 19 characters (16 voucher - 3 dividers) + return min( + res, + MAX_VOUCHER_LENGTH + MAX_VOUCHER_LENGTH / ACCOUNT_TOKEN_CHUNK_SIZE - 1 + ) + } + + override fun transformedToOriginal(offset: Int): Int = + offset - offset / (ACCOUNT_TOKEN_CHUNK_SIZE + 1) + } + ) +} 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 0000000000..a01aa08d8b --- /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/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 18e98964e8..63fcf17ad2 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 @@ -32,6 +32,7 @@ import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel import net.mullvad.mullvadvpn.viewmodel.ViewLogsViewModel +import net.mullvad.mullvadvpn.viewmodel.VoucherDialogViewModel import net.mullvad.mullvadvpn.viewmodel.VpnSettingsViewModel import net.mullvad.mullvadvpn.viewmodel.WelcomeViewModel import org.apache.commons.validator.routines.InetAddressValidator @@ -86,14 +87,15 @@ val uiModule = module { viewModel { DeviceListViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } viewModel { LoginViewModel(get(), get()) } + viewModel { OutOfTimeViewModel(get(), get()) } viewModel { PrivacyDisclaimerViewModel(get()) } + viewModel { ReportProblemViewModel(get()) } viewModel { SelectLocationViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } + viewModel { ViewLogsViewModel(get()) } + viewModel { VoucherDialogViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get()) } - viewModel { ReportProblemViewModel(get()) } - viewModel { ViewLogsViewModel(get()) } - viewModel { OutOfTimeViewModel(get(), get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" 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 46472ea6cc..61f24e45fc 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,45 @@ 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<VoucherDialogViewModel>() + 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<EditText>(R.id.voucher_code).apply { - addTextChangedListener(ValidVoucherCodeChecker()) + return inflater.inflate(R.layout.fragment_compose, container, false).apply { + findViewById<ComposeView>(R.id.compose_view).setContent { + AppTheme { + RedeemVoucherDialogScreen( + uiState = vm.uiState.collectAsState().value, + onVoucherInputChange = { vm.onVoucherInputChange(it) }, + 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<Button>(R.id.redeem).apply { - isEnabled = false - - setOnClickAction("action", jobTracker) { submitVoucher() } - } - - errorMessage = view.findViewById(R.id.error) - - view.findViewById<Button>(R.id.cancel).setOnClickAction("action", jobTracker) { - activity?.onBackPressed() } - - return view } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val dialog = super.onCreateDialog(savedInstanceState) - - dialog.window?.setBackgroundDrawable(ColorDrawable(android.R.color.transparent)) - - return dialog - } - - override fun onStart() { - super.onStart() - - dialog?.window?.setLayout(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) - } - - override fun onDestroyView() { - jobTracker.cancelAllJobs() - - super.onDestroyView() - } - - override fun onDetach() { - jobTracker.cancelJob("updateExpiry") - serviceConnectionManager.serviceNotifier.unsubscribe(this) - - super.onDetach() - } - - private fun updateRedeemButton() { - redeemButton?.isEnabled = voucherInputIsValid && voucherRedeemer != null - } - - private suspend fun submitVoucher() { - errorMessage.visibility = View.INVISIBLE - - val result = voucherRedeemer?.submit(voucherInput.text.toString()) - - when (result) { - is VoucherSubmissionResult.Ok -> handleAddedTime(result.submission.timeAdded) - is VoucherSubmissionResult.Error -> showError(result.error) - else -> { - /* NOOP */ - } - } - } - - private fun handleAddedTime(timeAdded: Long) { - if (timeAdded > 0) { - dismiss() - } - } - - private fun showError(error: VoucherSubmissionError) { - val message = - when (error) { - VoucherSubmissionError.InvalidVoucher -> R.string.invalid_voucher - VoucherSubmissionError.VoucherAlreadyUsed -> R.string.voucher_already_used - else -> R.string.error_occurred - } - - errorMessage.apply { - setText(message) - visibility = View.VISIBLE - } - } - - inner class ValidVoucherCodeChecker : TextWatcher { - private var editRecursionCount = 0 - - override fun beforeTextChanged(text: CharSequence, start: Int, count: Int, after: Int) { - editRecursionCount += 1 - } - - override fun onTextChanged(text: CharSequence, start: Int, before: Int, count: Int) {} - - override fun afterTextChanged(text: Editable) { - editRecursionCount -= 1 - - if (editRecursionCount == 0) { - voucherInputIsValid = text.length == FULL_VOUCHER_CODE_LENGTH - } - } + voucherDialog = super.onCreateDialog(savedInstanceState) + return voucherDialog } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt new file mode 100644 index 0000000000..07c56ff954 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModel.kt @@ -0,0 +1,92 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.content.res.Resources +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.state.LoginUiState +import net.mullvad.mullvadvpn.compose.state.VoucherDialogState +import net.mullvad.mullvadvpn.compose.state.VoucherDialogUiState +import net.mullvad.mullvadvpn.model.VoucherSubmissionError +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState +import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer + +class VoucherDialogViewModel( + serviceConnectionManager: ServiceConnectionManager, + private val resources: Resources +) : ViewModel() { + + private val vmState = MutableStateFlow<VoucherDialogState>(VoucherDialogState.Default) + private val voucherInput = MutableStateFlow(LoginUiState.INITIAL.accountNumberInput) + + private lateinit var voucherRedeemer: VoucherRedeemer + private val _shared: SharedFlow<ServiceConnectionContainer> = + serviceConnectionManager.connectionState + .flatMapLatest { state -> + if (state is ServiceConnectionState.ConnectedReady) { + flowOf(state.container) + } else { + emptyFlow() + } + } + .shareIn(viewModelScope, SharingStarted.WhileSubscribed()) + + var uiState = + _shared + .flatMapLatest { serviceConnection -> + voucherRedeemer = serviceConnection.voucherRedeemer + combine(vmState, voucherInput) { state, input -> + VoucherDialogUiState(voucherInput = input, voucherViewModelState = state) + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), VoucherDialogUiState.INITIAL) + + fun onRedeem(voucherCode: String) { + vmState.update { VoucherDialogState.Verifying } + viewModelScope.launch { + when (val result = voucherRedeemer.submit(voucherCode)) { + is VoucherSubmissionResult.Ok -> handleAddedTime(result.submission.timeAdded) + is VoucherSubmissionResult.Error -> setError(result.error) + else -> { + vmState.update { VoucherDialogState.Default } + } + } + } + } + + fun onVoucherInputChange(voucherString: String) { + voucherInput.value = voucherString + } + + private fun handleAddedTime(timeAdded: Long) { + viewModelScope.launch { vmState.update { VoucherDialogState.Success(timeAdded) } } + } + + private fun setError(error: VoucherSubmissionError) { + viewModelScope.launch { + val message = + resources.getString( + when (error) { + VoucherSubmissionError.InvalidVoucher -> R.string.invalid_voucher + VoucherSubmissionError.VoucherAlreadyUsed -> R.string.voucher_already_used + else -> R.string.error_occurred + } + ) + vmState.update { VoucherDialogState.Error(message) } + } + } +} diff --git a/android/app/src/main/res/layout/redeem_voucher.xml b/android/app/src/main/res/layout/redeem_voucher.xml deleted file mode 100644 index 3f6d91d183..0000000000 --- a/android/app/src/main/res/layout/redeem_voucher.xml +++ /dev/null @@ -1,58 +0,0 @@ -<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" - xmlns:mullvad="http://schemas.android.com/apk/res-auto" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:scrollbars="none"> - <LinearLayout android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="30dp" - android:background="@drawable/dialog_background" - android:orientation="vertical" - android:gravity="start"> - <TextView android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_weight="0" - android:layout_marginBottom="9dp" - android:textColor="@color/white" - android:textSize="@dimen/text_medium" - android:text="@string/enter_voucher_code" /> - <EditText android:id="@+id/voucher_code" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:padding="14dp" - android:background="@drawable/edit_text_background" - android:singleLine="true" - android:imeActionLabel="@string/redeem" - android:imeOptions="flagNoPersonalizedLearning" - android:inputType="textCapCharacters" - android:textCursorDrawable="@drawable/text_input_cursor" - android:hint="@string/voucher_hint" - android:maxLength="19" - android:digits="0123456789-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" - android:textAllCaps="true" - android:textColorHint="@color/blue40" - android:textColor="@color/blue" - android:textSize="@dimen/text_small" - android:textStyle="bold" /> - <TextView android:id="@+id/error" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:layout_marginTop="8dp" - android:textColor="@color/red" - android:textSize="@dimen/text_small" - android:textStyle="bold" - android:visibility="invisible" /> - <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/redeem" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginVertical="@dimen/button_separation" - mullvad:showSpinner="true" - mullvad:buttonColor="green" - mullvad:text="@string/redeem" /> - <net.mullvad.mullvadvpn.ui.widget.Button android:id="@+id/cancel" - android:layout_width="match_parent" - android:layout_height="wrap_content" - mullvad:buttonColor="blue" - mullvad:text="@string/cancel" /> - </LinearLayout> -</ScrollView> diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt new file mode 100644 index 0000000000..1c6240ba76 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/VoucherDialogViewModelTest.kt @@ -0,0 +1,71 @@ +package net.mullvad.mullvadvpn.viewmodel + +import android.content.res.Resources +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 kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.model.VoucherSubmissionError +import net.mullvad.mullvadvpn.model.VoucherSubmissionResult +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionContainer +import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager +import net.mullvad.mullvadvpn.ui.serviceconnection.VoucherRedeemer +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class VoucherDialogViewModelTest { + @get:Rule val testCoroutineRule = TestCoroutineRule() + + private val mockServiceConnectionManager: ServiceConnectionManager = mockk() + private val mockVoucherRedeemer: VoucherRedeemer = mockk() + private val mockServiceConnectionContainer: ServiceConnectionContainer = mockk() + private val mockResources: Resources = mockk() + + private val mockVoucherSubmissionErrorResult: VoucherSubmissionResult = + VoucherSubmissionResult.Error(VoucherSubmissionError.OtherError) + + private lateinit var viewModel: VoucherDialogViewModel + + @Before + fun setUp() { + mockkStatic(CACHE_EXTENSION_CLASS) + every { mockServiceConnectionManager.connectionState.value.readyContainer() } returns + mockServiceConnectionContainer + every { mockServiceConnectionContainer.voucherRedeemer } returns mockVoucherRedeemer + + viewModel = + VoucherDialogViewModel( + serviceConnectionManager = mockServiceConnectionManager, + resources = mockResources + ) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun test_submit_invalid_voucher() = runTest { + val voucher = DUMMY_VALID_VOUCHER + val dummyStringResource = DUMMY_STRING_RESOURCE + // Arrange + every { mockResources.getString(any()) } returns dummyStringResource + coEvery { mockVoucherRedeemer.submit(voucher) } returns mockVoucherSubmissionErrorResult + // Act, Assert + viewModel.onRedeem(voucher) + coVerify(exactly = 1) { mockVoucherRedeemer.submit(voucher) } + } + + companion object { + private const val CACHE_EXTENSION_CLASS = "net.mullvad.mullvadvpn.util.CacheExtensionsKt" + private const val DUMMY_VALID_VOUCHER = "DUMMY_VALID_VOUCHER" + private const val DUMMY_STRING_RESOURCE = "DUMMY_STRING_RESOURCE" + } +} diff --git a/android/lib/resource/src/main/res/values-da/strings.xml b/android/lib/resource/src/main/res/values-da/strings.xml index 83d38568fa..bb26081112 100644 --- a/android/lib/resource/src/main/res/values-da/strings.xml +++ b/android/lib/resource/src/main/res/values-da/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Se app-logfiler</string> <string name="virtual_adapter_problem">Fejl ved virtuel adapter</string> <string name="voucher_already_used">Kuponkode er allerede brugt.</string> + <string name="voucher_success_title">Indløsning af kuponen lykkedes.</string> <string name="vpn_permission_denied_error">VPN-tilladelse blev nægtet, da tunnelen blev oprettet. Prøv at oprette forbindelse igen.</string> <string name="vpn_permission_error_notification_message">Altid-til VPN er måske aktiveret for en anden app</string> <string name="vpn_permission_error_notification_title">VPN-tilladelsesfejl</string> diff --git a/android/lib/resource/src/main/res/values-de/strings.xml b/android/lib/resource/src/main/res/values-de/strings.xml index cac9b2897a..357e4209e3 100644 --- a/android/lib/resource/src/main/res/values-de/strings.xml +++ b/android/lib/resource/src/main/res/values-de/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">App-Protokolle anzeigen</string> <string name="virtual_adapter_problem">Virtueller Adapterfehler</string> <string name="voucher_already_used">Der Gutscheincode wurde bereits verwendet.</string> + <string name="voucher_success_title">Der Gutschein wurde erfolgreich eingelöst.</string> <string name="vpn_permission_denied_error">VPN-Berechtigungen wurden beim Erstellen des Tunnels abgelehnt.</string> <string name="vpn_permission_error_notification_message">Always-on VPN könnte für eine andere App aktiviert sein</string> <string name="vpn_permission_error_notification_title">VPN-Berechtigungsfehler</string> diff --git a/android/lib/resource/src/main/res/values-es/strings.xml b/android/lib/resource/src/main/res/values-es/strings.xml index 9fa273193b..1ca4ad0fa5 100644 --- a/android/lib/resource/src/main/res/values-es/strings.xml +++ b/android/lib/resource/src/main/res/values-es/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Ver registros de la aplicación</string> <string name="virtual_adapter_problem">Error del adaptador virtual</string> <string name="voucher_already_used">El código del cupón ya se ha usado.</string> + <string name="voucher_success_title">El cupón se canjeó correctamente.</string> <string name="vpn_permission_denied_error">Se denegó el permiso para usar una conexión VPN al crear el túnel. Intente volver a establecer la conexión.</string> <string name="vpn_permission_error_notification_message">La VPN siempre activa podría estar habilitada en otra aplicación</string> <string name="vpn_permission_error_notification_title">Error en la autorización de la VPN</string> diff --git a/android/lib/resource/src/main/res/values-fi/strings.xml b/android/lib/resource/src/main/res/values-fi/strings.xml index 90226d37cc..eb02e63756 100644 --- a/android/lib/resource/src/main/res/values-fi/strings.xml +++ b/android/lib/resource/src/main/res/values-fi/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Tarkastele sovelluslokeja</string> <string name="virtual_adapter_problem">Virtuaalisovittimen virhe</string> <string name="voucher_already_used">Kuponkikoodi on jo käytetty.</string> + <string name="voucher_success_title">Kupongin lunastus onnistui.</string> <string name="vpn_permission_denied_error">VPN-lupa evättiin tunnelia luotaessa. Yritä muodostaa yhteys uudelleen.</string> <string name="vpn_permission_error_notification_message">Aina päällä oleva VPN on mahdollisesti otettu käyttöön toiselle sovellukselle</string> <string name="vpn_permission_error_notification_title">VPN-lupavirhe</string> diff --git a/android/lib/resource/src/main/res/values-fr/strings.xml b/android/lib/resource/src/main/res/values-fr/strings.xml index 6ddea69f93..da970c6d89 100644 --- a/android/lib/resource/src/main/res/values-fr/strings.xml +++ b/android/lib/resource/src/main/res/values-fr/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Afficher les journaux de l\'application</string> <string name="virtual_adapter_problem">Erreur d\'adaptateur virtuel</string> <string name="voucher_already_used">Le code du bon a déjà été utilisé.</string> + <string name="voucher_success_title">Le bon a bien été échangé.</string> <string name="vpn_permission_denied_error">La permission VPN a été refusée lors de la création du tunnel. Veuillez essayer de vous reconnecter.</string> <string name="vpn_permission_error_notification_message">« Toujours exiger un VPN » est peut-être activé pour une autre application</string> <string name="vpn_permission_error_notification_title">Erreur de permission VPN</string> diff --git a/android/lib/resource/src/main/res/values-it/strings.xml b/android/lib/resource/src/main/res/values-it/strings.xml index 4a5c8854fa..c988e760cf 100644 --- a/android/lib/resource/src/main/res/values-it/strings.xml +++ b/android/lib/resource/src/main/res/values-it/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Visualizza registri app</string> <string name="virtual_adapter_problem">Errore scheda virtuale</string> <string name="voucher_already_used">Il codice voucher è già stato utilizzato.</string> + <string name="voucher_success_title">Il voucher è stato riscattato correttamente.</string> <string name="vpn_permission_denied_error">L\'autorizzazione VPN è stata negata durante la creazione del tunnel. Prova a connetterti di nuovo.</string> <string name="vpn_permission_error_notification_message">La VPN sempre attiva potrebbe essere abilitata per un\'altra app</string> <string name="vpn_permission_error_notification_title">Errore di autorizzazione VPN</string> diff --git a/android/lib/resource/src/main/res/values-ja/strings.xml b/android/lib/resource/src/main/res/values-ja/strings.xml index b00d94dd7d..8c9ef84739 100644 --- a/android/lib/resource/src/main/res/values-ja/strings.xml +++ b/android/lib/resource/src/main/res/values-ja/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">アプリのログを表示</string> <string name="virtual_adapter_problem">仮想アダプタのエラー</string> <string name="voucher_already_used">バウチャーコードはすでに使用されています。</string> + <string name="voucher_success_title">バウチャーを正常に使用しました。</string> <string name="vpn_permission_denied_error">トンネルを作成中にVPNへのアクセスが拒否されました。もう一度接続してみてください。</string> <string name="vpn_permission_error_notification_message">Always-on VPNが別のアプリで有効になっている可能性があります</string> <string name="vpn_permission_error_notification_title">VPN許可エラー</string> diff --git a/android/lib/resource/src/main/res/values-ko/strings.xml b/android/lib/resource/src/main/res/values-ko/strings.xml index c72dabb4f0..209023a64e 100644 --- a/android/lib/resource/src/main/res/values-ko/strings.xml +++ b/android/lib/resource/src/main/res/values-ko/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">앱 로그 보기</string> <string name="virtual_adapter_problem">가상 어댑터 오류</string> <string name="voucher_already_used">이미 사용된 바우처 코드입니다.</string> + <string name="voucher_success_title">바우처가 성공적으로 사용되었습니다.</string> <string name="vpn_permission_denied_error">터널을 만드는 동안 VPN 사용 권한이 거부되었습니다. 다시 연결해 보세요.</string> <string name="vpn_permission_error_notification_message">상시 접속 VPN이 다른 앱에 활성화되었을 수 있습니다.</string> <string name="vpn_permission_error_notification_title">VPN 권한 오류</string> diff --git a/android/lib/resource/src/main/res/values-my/strings.xml b/android/lib/resource/src/main/res/values-my/strings.xml index 7ce418ab09..0aef2a2c2e 100644 --- a/android/lib/resource/src/main/res/values-my/strings.xml +++ b/android/lib/resource/src/main/res/values-my/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">အက်ပ်မှတ်တမ်းများ ကြည့်ရန်</string> <string name="virtual_adapter_problem">စက်တွင်း အဒက်တာ ချို့ယွင်းချက်</string> <string name="voucher_already_used">ဘောက်ချာကုဒ် သုံးထားပြီးသား ဖြစ်ပါသည်။</string> + <string name="voucher_success_title">ဘောက်ချာကို အောင်မြင်စွာ လဲယူခဲ့ပါသည်။</string> <string name="vpn_permission_denied_error">Tunnel ဖန်တီးနေစဉ် VPN ခွင့်ပြုချက်ကို ပယ်ချခဲ့ပါသည်။ ထပ်မံချိတ်ဆက်ပေးပါ။</string> <string name="vpn_permission_error_notification_message">အမြဲဖွင့် VPN ကို နောက်ထပ်အက်ပ်အတွက် ဖွင့်ထားနိုင်ပါသည်</string> <string name="vpn_permission_error_notification_title">VPN ခွင့်ပြုချက် ချို့ယွင်းချက်</string> diff --git a/android/lib/resource/src/main/res/values-nb/strings.xml b/android/lib/resource/src/main/res/values-nb/strings.xml index c852f8916f..2b0e370bb3 100644 --- a/android/lib/resource/src/main/res/values-nb/strings.xml +++ b/android/lib/resource/src/main/res/values-nb/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Se applogger</string> <string name="virtual_adapter_problem">Virtuell adapterfeil</string> <string name="voucher_already_used">Kupongkoden er allerede brukt.</string> + <string name="voucher_success_title">Kupongkoden er løst inn.</string> <string name="vpn_permission_denied_error">VPN-tillatelse ble avvist under opprettelsen av tunnelen. Prøv å koble til igjen.</string> <string name="vpn_permission_error_notification_message">VPN som alltid er på, kan være aktivert for en annen app</string> <string name="vpn_permission_error_notification_title">Feil med VPN-tillatelse</string> diff --git a/android/lib/resource/src/main/res/values-nl/strings.xml b/android/lib/resource/src/main/res/values-nl/strings.xml index 3d518bae5e..cdbaa554c3 100644 --- a/android/lib/resource/src/main/res/values-nl/strings.xml +++ b/android/lib/resource/src/main/res/values-nl/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Applogboeken weergeven</string> <string name="virtual_adapter_problem">Fout virtuele adapter</string> <string name="voucher_already_used">Vouchercode is al gebruikt.</string> + <string name="voucher_success_title">Voucher is ingewisseld.</string> <string name="vpn_permission_denied_error">VPN-toestemming is geweigerd tijdens maken van de tunnel. Probeer opnieuw verbinding te maken.</string> <string name="vpn_permission_error_notification_message">Altijd-aan VPN is mogelijk ingeschakeld voor een andere app</string> <string name="vpn_permission_error_notification_title">VPN-machtigingsfout</string> diff --git a/android/lib/resource/src/main/res/values-pl/strings.xml b/android/lib/resource/src/main/res/values-pl/strings.xml index eb8a91d7e1..2e2e6ee267 100644 --- a/android/lib/resource/src/main/res/values-pl/strings.xml +++ b/android/lib/resource/src/main/res/values-pl/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Wyświetl dzienniki aplikacji</string> <string name="virtual_adapter_problem">Błąd wirtualnej karty sieciowej</string> <string name="voucher_already_used">Kod z tego kuponu został już użyty.</string> + <string name="voucher_success_title">Kupon został zrealizowany.</string> <string name="vpn_permission_denied_error">Uprawnienie VPN zostało odrzucone podczas tworzenia tunelu. Spróbuj połączyć się ponownie.</string> <string name="vpn_permission_error_notification_message">Opcja „Zawsze włączony VPN” może być włączona dla innej aplikacji</string> <string name="vpn_permission_error_notification_title">Błąd uprawnienia VPN</string> diff --git a/android/lib/resource/src/main/res/values-pt/strings.xml b/android/lib/resource/src/main/res/values-pt/strings.xml index a3ac329681..2fee06cab6 100644 --- a/android/lib/resource/src/main/res/values-pt/strings.xml +++ b/android/lib/resource/src/main/res/values-pt/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Ver os registos da app</string> <string name="virtual_adapter_problem">Erro de adaptador virtual</string> <string name="voucher_already_used">O código do voucher já foi utilizado.</string> + <string name="voucher_success_title">O voucher foi reclamado com sucesso.</string> <string name="vpn_permission_denied_error">A transmissão foi negada durante a criação do túnel. Tente fazer novamente a ligação.</string> <string name="vpn_permission_error_notification_message">A VPN sempre ligada pode estar ativada para outra app</string> <string name="vpn_permission_error_notification_title">Erro de permissão da VPN</string> diff --git a/android/lib/resource/src/main/res/values-ru/strings.xml b/android/lib/resource/src/main/res/values-ru/strings.xml index e6b8c35e67..0fb01c88ad 100644 --- a/android/lib/resource/src/main/res/values-ru/strings.xml +++ b/android/lib/resource/src/main/res/values-ru/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Открыть журналы</string> <string name="virtual_adapter_problem">Ошибка виртуального адаптера</string> <string name="voucher_already_used">Этот код ваучера уже использовался.</string> + <string name="voucher_success_title">Ваучер погашен.</string> <string name="vpn_permission_denied_error">При создании туннеля в доступе к VPN было отказано. Попробуйте подключиться снова.</string> <string name="vpn_permission_error_notification_message">Опцию «VPN всегда вкл.» может быть включена для другого приложения</string> <string name="vpn_permission_error_notification_title">Ошибка разрешения для VPN</string> diff --git a/android/lib/resource/src/main/res/values-sv/strings.xml b/android/lib/resource/src/main/res/values-sv/strings.xml index 7606a30e6b..c65809dc5d 100644 --- a/android/lib/resource/src/main/res/values-sv/strings.xml +++ b/android/lib/resource/src/main/res/values-sv/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Visa appens loggar</string> <string name="virtual_adapter_problem">Fel med virtuell adapter</string> <string name="voucher_already_used">Kupongkoden har redan använts.</string> + <string name="voucher_success_title">Kupongen har lösts in.</string> <string name="vpn_permission_denied_error">VPN-behörighet nekades när tunneln skapades. Försök att ansluta igen.</string> <string name="vpn_permission_error_notification_message">VPN som alltid är på kan ha aktiverats för annan app</string> <string name="vpn_permission_error_notification_title">Behörighetsfel med VPN</string> diff --git a/android/lib/resource/src/main/res/values-th/strings.xml b/android/lib/resource/src/main/res/values-th/strings.xml index 86484e4137..3f01840e0a 100644 --- a/android/lib/resource/src/main/res/values-th/strings.xml +++ b/android/lib/resource/src/main/res/values-th/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">ดูบันทึกของแอป</string> <string name="virtual_adapter_problem">ข้อผิดพลาดของอะแดปเตอร์เสมือน</string> <string name="voucher_already_used">รหัสบัตรกำนัลถูกใช้ไปแล้ว</string> + <string name="voucher_success_title">แลกบัตรกำนัลสำเร็จแล้ว</string> <string name="vpn_permission_denied_error">การให้สิทธิ์ VPN ถูกปฏิเสธ ในขณะที่สร้างอุโมงค์ โปรดลองเชื่อมต่อใหม่อีกครั้ง</string> <string name="vpn_permission_error_notification_message">Always-on VPN อาจได้รับการเปิดใช้งานสำหรับแอปอื่น</string> <string name="vpn_permission_error_notification_title">เกิดข้อผิดพลาดในการอนุญาต VPN</string> diff --git a/android/lib/resource/src/main/res/values-tr/strings.xml b/android/lib/resource/src/main/res/values-tr/strings.xml index b91a7e0bc0..08ff5f47e6 100644 --- a/android/lib/resource/src/main/res/values-tr/strings.xml +++ b/android/lib/resource/src/main/res/values-tr/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">Uygulama kayıtlarını görüntüle</string> <string name="virtual_adapter_problem">Sanal adaptör hatası</string> <string name="voucher_already_used">Kupon kodu zaten kullanılmış.</string> + <string name="voucher_success_title">Kupon başarıyla kullanıldı.</string> <string name="vpn_permission_denied_error">Tünel oluşturulurken VPN izni reddedildi. Lütfen tekrar bağlanmayı deneyin.</string> <string name="vpn_permission_error_notification_message">Her zaman açık VPN başka bir uygulama için etkinleştirilebilir</string> <string name="vpn_permission_error_notification_title">VPN izin hatası</string> diff --git a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml index 5082b2e004..174262c638 100644 --- a/android/lib/resource/src/main/res/values-zh-rCN/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rCN/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">查看应用日志</string> <string name="virtual_adapter_problem">虚拟适配器错误</string> <string name="voucher_already_used">该优惠券码已被使用。</string> + <string name="voucher_success_title">优惠券已成功兑换。</string> <string name="vpn_permission_denied_error">创建隧道时,VPN 权限被拒绝。请尝试重新连接。</string> <string name="vpn_permission_error_notification_message">可能为另一个应用启用了“始终启用 VPN”</string> <string name="vpn_permission_error_notification_title">VPN 权限错误</string> diff --git a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml index e1daa0c67e..70b0d42c55 100644 --- a/android/lib/resource/src/main/res/values-zh-rTW/strings.xml +++ b/android/lib/resource/src/main/res/values-zh-rTW/strings.xml @@ -192,6 +192,7 @@ <string name="view_logs">檢視應用程式日誌</string> <string name="virtual_adapter_problem">虛擬配接器錯誤</string> <string name="voucher_already_used">此憑證兌換碼已有人用過。</string> + <string name="voucher_success_title">憑證已成功兌換。</string> <string name="vpn_permission_denied_error">建立通道時,VPN 權限被拒絕。請嘗試重新連線。</string> <string name="vpn_permission_error_notification_message">可能已為另一個應用程式啟用了「始終啟用 VPN」</string> <string name="vpn_permission_error_notification_title">VPN 權限錯誤</string> diff --git a/android/lib/resource/src/main/res/values/plurals.xml b/android/lib/resource/src/main/res/values/plurals.xml index b9aa90441e..455d42c1f2 100644 --- a/android/lib/resource/src/main/res/values/plurals.xml +++ b/android/lib/resource/src/main/res/values/plurals.xml @@ -40,4 +40,12 @@ <item quantity="one">Account credit expires in an hour</item> <item quantity="other">Account credit expires in %d hours</item> </plurals> + <plurals name="days"> + <item quantity="one">%d day</item> + <item quantity="other">%d days</item> + </plurals> + <plurals name="months"> + <item quantity="one">%d month</item> + <item quantity="other">%d months</item> + </plurals> </resources> diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 988b343c19..bc9630e974 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -220,4 +220,8 @@ <string name="custom_port_dialog_remove">Remove custom port</string> <string name="custom_port_dialog_valid_ranges">Valid ranges: %s</string> <string name="custom_port_dialog_placeholder">Enter port</string> + <string name="voucher_success_title">Voucher was successfully redeemed.</string> + <string name="verifying_voucher">Verifying voucher…</string> + <string name="added_to_your_account">%s was added to your account.</string> + <string name="less_than_one_day">less than one day</string> </resources> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index c3fd722a12..bb56f7df48 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -55,6 +55,7 @@ data class Dimensions( val sideMargin: Dp = 22.dp, val smallPadding: Dp = 8.dp, val spacingAboveButton: Dp = 22.dp, + val successIconVerticalPadding: Dp = 26.dp, val titleIconSize: Dp = 24.dp, val topBarHeight: Dp = 64.dp, val verticalSpace: Dp = 20.dp, diff --git a/gui/locales/messages.pot b/gui/locales/messages.pot index 61f4d4be5a..d28aac8520 100644 --- a/gui/locales/messages.pot +++ b/gui/locales/messages.pot @@ -1653,6 +1653,9 @@ msgctxt "wireguard-settings-view" msgid "Which TCP port the UDP-over-TCP obfuscation protocol should connect to on the VPN server." msgstr "" +msgid "%s was added to your account." +msgstr "" + msgid "Account credit expires in a few minutes" msgstr "" @@ -1818,6 +1821,9 @@ msgstr "" msgid "Valid ranges: %s" msgstr "" +msgid "Verifying voucher…" +msgstr "" + msgid "Virtual adapter error" msgstr "" @@ -1845,6 +1851,19 @@ msgstr "" msgid "less than a minute ago" msgstr "" +msgid "less than one day" +msgstr "" + +msgid "%d day" +msgid_plural "%d days" +msgstr[0] "" +msgstr[1] "" + +msgid "%d month" +msgid_plural "%d months" +msgstr[0] "" +msgstr[1] "" + msgid "Account credit expires in a day" msgid_plural "Account credit expires in %d days" msgstr[0] "" |
