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..b4e413afb6dc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RedeemVoucherDialog.kt @@ -0,0 +1,184 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +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) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, device = Devices.PIXEL_3) +@Composable +private fun PreviewRedeemVoucherDialog() { + AppTheme { + RedeemVoucherDialog( + uiState = VoucherDialogUiState(null), + onRedeem = {}, + onDismiss = {} + ) + } +} + +@Composable +fun RedeemVoucherDialog( + uiState: VoucherDialogUiState, + onRedeem: (voucherCode: String) -> Unit, + onDismiss: () -> Unit +) { + val voucher = remember { mutableStateOf("") } + + AlertDialog( + title = { + Text( + text = stringResource(id = R.string.enter_voucher_code), + style = MaterialTheme.typography.titleMedium + ) + }, + confirmButton = { + Column { + ActionButton( + text = stringResource(id = R.string.redeem), + onClick = { onRedeem(voucher.value) }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = + MaterialTheme.colorScheme.onSurface.copy(alpha = AlphaInactive), + disabledContainerColor = + MaterialTheme.colorScheme.surface.copy(alpha = AlphaDisabled) + ), + isEnabled = voucher.value.length == VOUCHER_LENGTH + ) + ActionButton( + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + text = stringResource(id = R.string.cancel), + modifier = Modifier.padding(top = Dimens.mediumPadding), + onClick = onDismiss + ) + } + }, + text = { + Column { + GroupedTextField( + value = voucher.value, + onSubmit = { input -> + if (input.isNotEmpty()) { + onRedeem(input) + } + }, + onValueChanged = { input -> + voucher.value = input.uppercase().format().replace(" ", "").replace("-", "") + }, + isValidValue = voucher.value.isNotEmpty(), + keyboardType = KeyboardType.Text, + placeholderText = stringResource(id = R.string.voucher_hint), + placeHolderColor = + MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaDisabled), + visualTransformation = { voucher -> formatOtherVouchers(voucher) }, + 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) + ) { + if (uiState.showLoading) { + 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 { + + Text( + text = uiState.message ?: "", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + }, + containerColor = MaterialTheme.colorScheme.background, + titleContentColor = MaterialTheme.colorScheme.onBackground, + onDismissRequest = onDismiss + ) +} + +private fun formatOtherVouchers(text: AnnotatedString): TransformedText { + val trimmed = if (text.text.length >= 16) text.text.substring(0..15) else text.text + var out = "" + + 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 { + if (offset <= 3) return offset + if (offset <= 7) return offset + 1 + if (offset <= 11) return offset + 2 + if (offset <= 16) return offset + 3 + return 19 + } + + override fun transformedToOriginal(offset: Int): Int { + if (offset <= 4) return offset + if (offset <= 9) return offset - 1 + if (offset <= 14) return offset - 2 + if (offset <= 19) return offset - 3 + return 16 + } + } + + 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..94babc435ca1 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/RedeemVoucherDialogScreen.kt @@ -0,0 +1,33 @@ +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 kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +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(null), + 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/compose/state/VoucherDialogUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt new file mode 100644 index 000000000000..0f0f9d0d0e89 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/VoucherDialogUiState.kt @@ -0,0 +1,29 @@ +package net.mullvad.mullvadvpn.compose.state + +data class VoucherDialogUiState( + var message: String? = null, + var isError: Boolean = false, + var showLoading: Boolean = false +) + +sealed class VoucherDialogViewModelState { + data object Default : VoucherDialogViewModelState() + + data object Verifying : VoucherDialogViewModelState() + + data class Success(var message: String?) : VoucherDialogViewModelState() + + data class Error(val errorMessage: String) : VoucherDialogViewModelState() + + fun toUiState(): VoucherDialogUiState { + return when (this) { + is Default -> VoucherDialogUiState(message = null, isError = false, showLoading = false) + is Verifying -> + VoucherDialogUiState(message = null, isError = false, showLoading = true) + is Success -> + VoucherDialogUiState(message = message, isError = false, showLoading = false) + is Error -> + VoucherDialogUiState(message = errorMessage, isError = true, showLoading = false) + } + } +} 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 000000000000..8206f9e3e989 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/textfield/GroupedTextField.kt @@ -0,0 +1,203 @@ +package net.mullvad.mullvadvpn.compose.textfield + +import android.text.TextUtils +import android.view.KeyEvent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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 net.mullvad.mullvadvpn.lib.theme.AlphaInactive + +private const val EMPTY_STRING = "" +private const val NEWLINE_STRING = "\n" + +@Composable +@OptIn(ExperimentalComposeUiApi::class) +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 +) { + val shape = RoundedCornerShape(4.dp) + val textFieldHeight = 44.dp + + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + var isFocused by remember { mutableStateOf(false) } + + val textColor = + when { + isValidValue.not() -> MaterialTheme.colorScheme.error + isFocused -> MaterialTheme.colorScheme.primary + else -> defaultTextColor + } + + val placeholderTextColor = + if (isFocused) { + placeHolderColor + } else { + MaterialTheme.colorScheme.onPrimary + } + + val backgroundColor = + if (isFocused) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onPrimary.copy(AlphaInactive) + } + + fun triggerSubmit() { + keyboardController?.hide() + focusManager.moveFocus(FocusDirection.Previous) + onSubmit(value) + } + + FilteredTextField( + value = value, + onChanged = { input -> + val isValidInput = if (isDigitsOnlyAllowed) TextUtils.isDigitsOnly(input) else true + if (input.length <= maxCharLength && isValidInput) { + // Remove any newline chars added by enter key clicks + onValueChanged(input.replace(NEWLINE_STRING, EMPTY_STRING)) + } + }, + textStyle = MaterialTheme.typography.titleMedium.copy(color = textColor), + isEnabled = isEnabled, + singleLine = true, + maxLines = 1, + visualTransformation = visualTransformation, + keyboardOptions = + KeyboardOptions( + keyboardType = keyboardType, + imeAction = ImeAction.Done, + autoCorrect = false, + ), + keyboardActions = KeyboardActions(onDone = { triggerSubmit() }), + decorationBox = { decorationBox -> + Box(modifier = Modifier.padding(PaddingValues(12.dp, 10.dp)).fillMaxWidth()) { + if (value.isBlank()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.titleMedium, + color = placeholderTextColor, + textAlign = textAlign, + modifier = Modifier.fillMaxWidth() + ) + } + decorationBox() + } + }, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + validateRegex = validateRegex, + modifier = + modifier + .background(backgroundColor) + .clip(shape) + .onFocusChanged { focusState -> + isFocused = focusState.isFocused + onFocusChange(focusState.isFocused) + } + .height(textFieldHeight) + .onKeyEvent { keyEvent -> + return@onKeyEvent when (keyEvent.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_ENTER -> { + triggerSubmit() + true + } + KeyEvent.KEYCODE_ESCAPE -> { + focusManager.clearFocus(force = true) + keyboardController?.hide() + true + } + KeyEvent.KEYCODE_DPAD_DOWN -> { + focusManager.moveFocus(FocusDirection.Down) + true + } + KeyEvent.KEYCODE_DPAD_UP -> { + focusManager.moveFocus(FocusDirection.Up) + true + } + else -> { + false + } + } + } + ) +} + +@Composable +fun FilteredTextField( + value: String, + onChanged: (String) -> Unit, + isEnabled: Boolean, + singleLine: Boolean, + maxLines: Int, + visualTransformation: VisualTransformation, + textStyle: TextStyle, + cursorBrush: Brush, + keyboardOptions: KeyboardOptions, + keyboardActions: KeyboardActions, + validateRegex: Regex, + modifier: Modifier = Modifier, + decorationBox: @Composable (innerTextField: @Composable () -> Unit) -> Unit = + @Composable { innerTextField -> innerTextField() } +) { + BasicTextField( + value = value, + enabled = isEnabled, + singleLine = singleLine, + maxLines = maxLines, + visualTransformation = visualTransformation, + textStyle = textStyle, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + decorationBox = decorationBox, + cursorBrush = cursorBrush, + modifier = modifier, + onValueChange = { if (validateRegex.matches(it)) onChanged(it) }, + ) +} 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/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt index 05e7548b9f32..b21a2e484871 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 @@ -28,6 +28,7 @@ import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel import net.mullvad.mullvadvpn.viewmodel.SplitTunnelingViewModel +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 @@ -84,6 +85,7 @@ val uiModule = module { viewModel { PrivacyDisclaimerViewModel(get()) } viewModel { SelectLocationViewModel(get()) } viewModel { SettingsViewModel(get(), get()) } + viewModel { VoucherDialogViewModel(get(), get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get()) } } 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..12db161f1500 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