From 9bec28519867652cffc4e67be19f20ba53ebfab6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Mon, 11 Sep 2023 10:18:48 +0200 Subject: [PATCH 1/2] Add copy icon to WelcomeScreen --- .../component/CopyableObfuscationView.kt | 54 +-- .../compose/component/Scaffolding.kt | 16 + .../compose/screen/WelcomeScreen.kt | 310 ++++++++++-------- .../mullvadvpn/compose/util/Clipboard.kt | 36 ++ 4 files changed, 256 insertions(+), 160 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt index bf33c12ddd97..ca3957ccb025 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt @@ -4,8 +4,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter @@ -27,8 +29,7 @@ private fun PreviewCopyableObfuscationView() { @Composable fun CopyableObfuscationView(content: String) { - val context = LocalContext.current - val shouldObfuscated = remember { mutableStateOf(true) } + var shouldObfuscated by remember { mutableStateOf(true) } Row( verticalAlignment = CenterVertically, @@ -36,7 +37,7 @@ fun CopyableObfuscationView(content: String) { ) { AccountNumberView( accountNumber = content, - doObfuscateWithPasswordDots = shouldObfuscated.value, + doObfuscateWithPasswordDots = shouldObfuscated, modifier = Modifier.weight(1f) ) AnimatedIconButton( @@ -44,25 +45,34 @@ fun CopyableObfuscationView(content: String) { secondaryIcon = painterResource(id = R.drawable.icon_show), isToggleButton = true, contentDescription = stringResource(id = R.string.hide_account_number), - onClick = { shouldObfuscated.value = shouldObfuscated.value.not() } - ) - AnimatedIconButton( - defaultIcon = painterResource(id = R.drawable.icon_copy), - secondaryIcon = painterResource(id = R.drawable.icon_tick), - secondaryIconColorFilter = - ColorFilter.tint(color = MaterialTheme.colorScheme.inversePrimary), - isToggleButton = false, - contentDescription = stringResource(id = R.string.copy_account_number), - onClick = { - context.copyToClipboard( - content = content, - clipboardLabel = context.getString(R.string.mullvad_account_number) - ) - SdkUtils.showCopyToastIfNeeded( - context, - context.getString(R.string.copied_mullvad_account_number) - ) - } + onClick = { shouldObfuscated = !shouldObfuscated } ) + + val context = LocalContext.current + val copy = { + context.copyToClipboard( + content = content, + clipboardLabel = context.getString(R.string.mullvad_account_number) + ) + SdkUtils.showCopyToastIfNeeded( + context, + context.getString(R.string.copied_mullvad_account_number) + ) + } + + CopyAnimatedIconButton(onClick = copy) } } + +@Composable +fun CopyAnimatedIconButton(onClick: () -> Unit) { + AnimatedIconButton( + defaultIcon = painterResource(id = R.drawable.icon_copy), + secondaryIcon = painterResource(id = R.drawable.icon_tick), + secondaryIconColorFilter = + ColorFilter.tint(color = MaterialTheme.colorScheme.inversePrimary), + isToggleButton = false, + contentDescription = stringResource(id = R.string.copy_account_number), + onClick = onClick + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index 177cdc47e5db..e0f84db5ea21 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -5,6 +5,10 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -34,6 +38,7 @@ fun ScaffoldWithTopBar( onSettingsClicked: (() -> Unit)?, onAccountClicked: (() -> Unit)?, isIconAndLogoVisible: Boolean = true, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, content: @Composable (PaddingValues) -> Unit, ) { val systemUiController = rememberSystemUiController() @@ -52,10 +57,21 @@ fun ScaffoldWithTopBar( isIconAndLogoVisible = isIconAndLogoVisible ) }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) } + ) + }, content = content ) } +@Composable +fun MullvadSnackbar(snackbarData: SnackbarData) { + Snackbar(snackbarData = snackbarData, contentColor = MaterialTheme.colorScheme.secondary) +} + @Composable @OptIn(ExperimentalToolbarApi::class) fun CollapsableAwareToolbarScaffold( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt index 8bcfc7ab499d..b78f767f7688 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/WelcomeScreen.kt @@ -9,12 +9,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -35,11 +35,12 @@ import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.button.SitePaymentButton +import net.mullvad.mullvadvpn.compose.component.CopyAnimatedIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.dialog.InfoDialog import net.mullvad.mullvadvpn.compose.state.WelcomeUiState -import net.mullvad.mullvadvpn.lib.common.util.SdkUtils +import net.mullvad.mullvadvpn.compose.util.createCopyToClipboardHandle import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar @@ -78,7 +79,7 @@ fun WelcomeScreen( openConnectScreen: () -> Unit ) { val context = LocalContext.current - LaunchedEffect(key1 = Unit) { + LaunchedEffect(Unit) { viewActions.collect { viewAction -> when (viewAction) { is WelcomeViewModel.ViewAction.OpenAccountView -> @@ -88,6 +89,8 @@ fun WelcomeScreen( } } val scrollState = rememberScrollState() + val snackbarHostState = remember { SnackbarHostState() } + ScaffoldWithTopBar( topBarColor = if (uiState.tunnelState.isSecured()) { @@ -110,11 +113,10 @@ fun WelcomeScreen( } .copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClick, - onAccountClicked = onAccountClick + onAccountClicked = onAccountClick, + snackbarHostState = snackbarHostState ) { Column( - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.Start, modifier = Modifier.fillMaxSize() .verticalScroll(scrollState) @@ -122,152 +124,184 @@ fun WelcomeScreen( .background(color = MaterialTheme.colorScheme.primary) .padding(it) ) { - Text( - text = stringResource(id = R.string.congrats), - modifier = - Modifier.padding( + // Welcome info area + WelcomeInfo(snackbarHostState, uiState, showSitePayment) + + Spacer(modifier = Modifier.weight(1f)) + + // Payment button area + PaymentPanel(showSitePayment, onSitePaymentClick, onRedeemVoucherClick) + } + } +} + +@Composable +private fun WelcomeInfo( + snackbarHostState: SnackbarHostState, + uiState: WelcomeUiState, + showSitePayment: Boolean +) { + Column { + Text( + text = stringResource(id = R.string.congrats), + modifier = + Modifier.fillMaxWidth() + .padding( top = Dimens.screenVerticalMargin, start = Dimens.sideMargin, end = Dimens.sideMargin ), - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = stringResource(id = R.string.here_is_your_account_number), - modifier = - Modifier.padding( + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource(id = R.string.here_is_your_account_number), + modifier = + Modifier.fillMaxWidth() + .padding( + horizontal = Dimens.sideMargin, vertical = Dimens.smallPadding, - horizontal = Dimens.sideMargin ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = uiState.accountNumber?.groupWithSpaces() ?: "", - modifier = - Modifier.fillMaxWidth() - .wrapContentHeight() - .then( - uiState.accountNumber?.let { - Modifier.clickable { - context.copyToClipboard( - content = uiState.accountNumber, - clipboardLabel = - context.getString(R.string.mullvad_account_number) - ) - SdkUtils.showCopyToastIfNeeded( - context, - context.getString(R.string.copied_mullvad_account_number) - ) - } - } - ?: Modifier - ) - .padding(vertical = Dimens.smallPadding, horizontal = Dimens.sideMargin), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onPrimary - ) - Row( - modifier = Modifier.padding(horizontal = Dimens.sideMargin), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - modifier = Modifier.weight(1f, fill = false), - text = - buildString { - append(stringResource(id = R.string.device_name)) - append(": ") - append(uiState.deviceName) - }, - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onPrimary - ) + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary + ) - var showDeviceNameDialog by remember { mutableStateOf(false) } - IconButton( - modifier = Modifier.align(Alignment.CenterVertically), - onClick = { showDeviceNameDialog = true } - ) { - Icon( - painter = painterResource(id = R.drawable.icon_info), - contentDescription = null, - tint = MullvadWhite - ) - } - if (showDeviceNameDialog) { - InfoDialog( - message = - buildString { - appendLine( - stringResource(id = R.string.device_name_info_first_paragraph) - ) - appendLine() - appendLine( - stringResource(id = R.string.device_name_info_second_paragraph) - ) - appendLine() - appendLine( - stringResource(id = R.string.device_name_info_third_paragraph) - ) - }, - onDismiss = { showDeviceNameDialog = false } - ) - } - } - Text( - text = + AccountNumberRow(snackbarHostState, uiState) + + DeviceNameRow(deviceName = uiState.deviceName) + + Text( + text = + buildString { + append(stringResource(id = R.string.pay_to_start_using)) + if (showSitePayment) { + append(" ") + append(stringResource(id = R.string.add_time_to_account)) + } + }, + modifier = + Modifier.padding( + top = Dimens.smallPadding, + bottom = Dimens.verticalSpace, + start = Dimens.sideMargin, + end = Dimens.sideMargin + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary + ) + } +} + +@Composable +private fun AccountNumberRow(snackbarHostState: SnackbarHostState, uiState: WelcomeUiState) { + val copiedAccountNumberMessage = stringResource(id = R.string.copied_mullvad_account_number) + val copyToClipboard = createCopyToClipboardHandle(snackbarHostState = snackbarHostState) + val onCopyToClipboard = { + copyToClipboard(uiState.accountNumber ?: "", copiedAccountNumberMessage) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier.fillMaxWidth() + .clickable(onClick = onCopyToClipboard) + .padding(horizontal = Dimens.sideMargin) + ) { + Text( + text = uiState.accountNumber?.groupWithSpaces() ?: "", + modifier = Modifier.weight(1f).padding(vertical = Dimens.smallPadding), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onPrimary + ) + + CopyAnimatedIconButton(onCopyToClipboard) + } +} + +@Composable +fun DeviceNameRow(deviceName: String?) { + Row( + modifier = Modifier.padding(horizontal = Dimens.sideMargin), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f, fill = false), + text = + buildString { + append(stringResource(id = R.string.device_name)) + append(": ") + append(deviceName) + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onPrimary + ) + + var showDeviceNameDialog by remember { mutableStateOf(false) } + IconButton( + modifier = Modifier.align(Alignment.CenterVertically), + onClick = { showDeviceNameDialog = true } + ) { + Icon( + painter = painterResource(id = R.drawable.icon_info), + contentDescription = null, + tint = MullvadWhite + ) + } + if (showDeviceNameDialog) { + InfoDialog( + message = buildString { - append(stringResource(id = R.string.pay_to_start_using)) - if (showSitePayment) { - append(" ") - append(stringResource(id = R.string.add_time_to_account)) - } + appendLine(stringResource(id = R.string.device_name_info_first_paragraph)) + appendLine() + appendLine(stringResource(id = R.string.device_name_info_second_paragraph)) + appendLine() + appendLine(stringResource(id = R.string.device_name_info_third_paragraph)) }, + onDismiss = { showDeviceNameDialog = false } + ) + } + } +} + +@Composable +private fun PaymentPanel( + showSitePayment: Boolean, + onSitePaymentClick: () -> Unit, + onRedeemVoucherClick: () -> Unit +) { + Column( + modifier = + Modifier.fillMaxWidth() + .padding(top = Dimens.mediumPadding) + .background(color = MaterialTheme.colorScheme.background) + ) { + Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin)) + if (showSitePayment) { + SitePaymentButton( + onClick = onSitePaymentClick, + isEnabled = true, modifier = Modifier.padding( - top = Dimens.smallPadding, start = Dimens.sideMargin, end = Dimens.sideMargin, - bottom = Dimens.verticalSpace - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(modifier = Modifier.weight(1f)) - // Payment button area - Column( - modifier = - Modifier.fillMaxWidth() - .padding(top = Dimens.mediumPadding) - .background(color = MaterialTheme.colorScheme.background) - ) { - Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin)) - if (showSitePayment) { - SitePaymentButton( - onClick = onSitePaymentClick, - isEnabled = true, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.screenVerticalMargin - ) + bottom = Dimens.screenVerticalMargin ) - } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, - isEnabled = true, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.screenVerticalMargin - ) - ) - } + ) } + RedeemVoucherButton( + onClick = onRedeemVoucherClick, + isEnabled = true, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ) + ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt new file mode 100644 index 000000000000..6c5e80d6ede4 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Clipboard.kt @@ -0,0 +1,36 @@ +package net.mullvad.mullvadvpn.compose.util + +import android.os.Build +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import kotlinx.coroutines.launch + +typealias CopyToClipboardHandle = (content: String, toastMessage: String?) -> Unit + +@Composable +fun createCopyToClipboardHandle( + snackbarHostState: SnackbarHostState, +): CopyToClipboardHandle { + val scope = rememberCoroutineScope() + val clipboardManager: ClipboardManager = LocalClipboardManager.current + + return { textToCopy: String, toastMessage: String? -> + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && toastMessage != null) { + scope.launch { + // Dismiss to prevent queueing up of snackbar data. + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar( + message = toastMessage, + duration = SnackbarDuration.Short + ) + } + } + + clipboardManager.setText(AnnotatedString(textToCopy)) + } +} From d6adb5a9b05e8770a6accdea4879236d17fa439a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Tue, 19 Sep 2023 10:20:39 +0200 Subject: [PATCH 2/2] Clarify obfuscation variable --- .../mullvadvpn/compose/component/AccountNumberView.kt | 4 ++-- .../mullvadvpn/compose/component/CopyableObfuscationView.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt index 689ff5dfd552..df0ae4d299b9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/AccountNumberView.kt @@ -8,12 +8,12 @@ import net.mullvad.mullvadvpn.lib.common.util.groupWithSpaces @Composable fun AccountNumberView( accountNumber: String, - doObfuscateWithPasswordDots: Boolean, + obfuscateWithPasswordDots: Boolean, modifier: Modifier = Modifier ) { InformationView( content = - if (doObfuscateWithPasswordDots) accountNumber.groupPasswordModeWithSpaces() + if (obfuscateWithPasswordDots) accountNumber.groupPasswordModeWithSpaces() else accountNumber.groupWithSpaces(), modifier = modifier, whenMissing = MissingPolicy.SHOW_SPINNER diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt index ca3957ccb025..379f59857385 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CopyableObfuscationView.kt @@ -29,7 +29,7 @@ private fun PreviewCopyableObfuscationView() { @Composable fun CopyableObfuscationView(content: String) { - var shouldObfuscated by remember { mutableStateOf(true) } + var obfuscationEnabled by remember { mutableStateOf(true) } Row( verticalAlignment = CenterVertically, @@ -37,7 +37,7 @@ fun CopyableObfuscationView(content: String) { ) { AccountNumberView( accountNumber = content, - doObfuscateWithPasswordDots = shouldObfuscated, + obfuscateWithPasswordDots = obfuscationEnabled, modifier = Modifier.weight(1f) ) AnimatedIconButton( @@ -45,7 +45,7 @@ fun CopyableObfuscationView(content: String) { secondaryIcon = painterResource(id = R.drawable.icon_show), isToggleButton = true, contentDescription = stringResource(id = R.string.hide_account_number), - onClick = { shouldObfuscated = !shouldObfuscated } + onClick = { obfuscationEnabled = !obfuscationEnabled } ) val context = LocalContext.current