diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-04-15 15:59:12 +0200 |
|---|---|---|
| committer | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-04-22 12:12:12 +0200 |
| commit | 926468949b2facb49182c1a5a7597b720f9e5cc7 (patch) | |
| tree | 30950262e3f6adb784a12d89f8c8def37a604356 /android | |
| parent | 3a58848059a1fb8c429a49acff9e2f57b1d91fd2 (diff) | |
| download | mullvadvpn-926468949b2facb49182c1a5a7597b720f9e5cc7.tar.xz mullvadvpn-926468949b2facb49182c1a5a7597b720f9e5cc7.zip | |
Implement manage devices screen
Diffstat (limited to 'android')
33 files changed, 1095 insertions, 170 deletions
diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt index 88b783df83..6f7b848e8a 100644 --- a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreenTest.kt @@ -41,9 +41,9 @@ class AccountScreenTest { onManageAccountClick: () -> Unit = {}, onLogoutClick: () -> Unit = {}, onPurchaseBillingProductClick: (productId: ProductId) -> Unit = {}, - navigateToDeviceInfo: () -> Unit = {}, navigateToVerificationPendingDialog: () -> Unit = {}, onBackClick: () -> Unit = {}, + onManageDevicesClick: () -> Unit = {}, ) { setContentWithTheme { AccountScreen( @@ -53,9 +53,9 @@ class AccountScreenTest { onManageAccountClick = onManageAccountClick, onLogoutClick = onLogoutClick, onPurchaseBillingProductClick = onPurchaseBillingProductClick, - navigateToDeviceInfo = navigateToDeviceInfo, navigateToVerificationPendingDialog = navigateToVerificationPendingDialog, onBackClick = onBackClick, + onManageDevicesClick = onManageDevicesClick, ) } } diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt new file mode 100644 index 0000000000..a54064b885 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreenTest.kt @@ -0,0 +1,157 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import de.mannodermaus.junit5.compose.ComposeContext +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import java.time.ZonedDateTime +import net.mullvad.mullvadvpn.compose.createEdgeToEdgeComposeExtension +import net.mullvad.mullvadvpn.compose.setContentWithTheme +import net.mullvad.mullvadvpn.compose.state.ManageDevicesItemUiState +import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.util.withRole +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError +import net.mullvad.mullvadvpn.util.Lce +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension + +@ExperimentalTestApi +class ManageDevicesScreenTest { + @JvmField @RegisterExtension val composeExtension = createEdgeToEdgeComposeExtension() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + private fun ComposeContext.initScreen( + state: Lce<ManageDevicesUiState, GetDeviceListError>, + snackbarHostState: SnackbarHostState = SnackbarHostState(), + onBackClick: () -> Unit = {}, + onTryAgainClicked: () -> Unit = {}, + navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit = {}, + ) { + setContentWithTheme { + ManageDevicesScreen( + state = state, + snackbarHostState = snackbarHostState, + onBackClick = onBackClick, + onTryAgainClicked = onTryAgainClicked, + navigateToRemoveDeviceConfirmationDialog = navigateToRemoveDeviceConfirmationDialog, + ) + } + } + + @Test + fun loadingStateShowsProgressIndicator() { + composeExtension.use { + // Arrange + initScreen(state = Lce.Loading) + + // Assert + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertIsDisplayed() + } + } + + @Test + fun errorStateShowsErrorMessageAndTryAgainButton() { + composeExtension.use { + // Arrange + val onTryAgainClicked: () -> Unit = mockk(relaxed = true) + initScreen( + state = Lce.Error(GetDeviceListError.Unknown(Throwable("error"))), + onTryAgainClicked = onTryAgainClicked, + ) + + // Assert + onNodeWithText("Failed to fetch list of devices").assertIsDisplayed() + onNodeWithText("Try again").assertIsDisplayed() + onNodeWithText("Manage devices").assertIsDisplayed() + + // Act + onNodeWithText("Try again").performClick() + + // Assert + verify(exactly = 1) { onTryAgainClicked.invoke() } + } + } + + @Test + fun contentStateShowsDeviceListCorrectly() { + composeExtension.use { + // Arrange + val device1 = + Device( + id = DeviceId.fromString("12345678-1234-5678-1234-567812345678"), + name = "Laptop", + creationDate = ZonedDateTime.now().minusSeconds(100), + ) + val device2 = + Device( + id = DeviceId.fromString("87654321-1234-5678-1234-567812345678"), + name = "My Phone", + creationDate = ZonedDateTime.now().minusSeconds(200), + ) + + val device3 = + Device( + id = DeviceId.fromString("87654321-4321-5678-1234-567812345678"), + name = "Tablet", + creationDate = ZonedDateTime.now().minusSeconds(300), + ) + + val state = + ManageDevicesUiState( + devices = + listOf( + ManageDevicesItemUiState( + device2, + isLoading = false, + isCurrentDevice = true, + ), + ManageDevicesItemUiState( + device1, + isLoading = false, + isCurrentDevice = false, + ), + ManageDevicesItemUiState( + device3, + isLoading = true, + isCurrentDevice = false, + ), + ) + ) + initScreen(state = Lce.Content(state)) + + // Assert + onNodeWithText("Manage devices").assertIsDisplayed() + + onNodeWithText("Laptop").assertIsDisplayed() + onNodeWithText("Current device").assertIsDisplayed() + onNodeWithText("My Phone").assertIsDisplayed() + onNodeWithText("Tablet").assertIsDisplayed() + + // We should have 2 visible buttons (the navbar back button and the remove button for + // device "Laptop" + val buttons = onAllNodes(withRole(Role.Button)) + buttons.assertCountEquals(2) + buttons[0].assertIsDisplayed() + buttons[1].assertIsDisplayed() + + // Make sure the device that is loading is displaying the spinner + onNodeWithTag(CIRCULAR_PROGRESS_INDICATOR).assertIsDisplayed() + } + } +} diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/util/Matcher.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/util/Matcher.kt new file mode 100644 index 0000000000..636084a335 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/util/Matcher.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.compose.util + +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher + +fun withRole(role: Role): SemanticsMatcher = + SemanticsMatcher("${SemanticsProperties.Role.name} == '$role'") { + val roleProperty = it.config.getOrNull(SemanticsProperties.Role) ?: false + roleProperty == role + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt index bbe22c9b11..7160f16ba8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/MullvadButton.kt @@ -1,20 +1,25 @@ package net.mullvad.mullvadvpn.compose.button import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorSmall @@ -62,6 +67,18 @@ private fun PreviewPrimaryButtonDisabled() { AppTheme { PrimaryButton(onClick = {}, text = "Primary Button", isEnabled = false) } } +@Preview +@Composable +private fun PreviewTextButtonEnabled() { + AppTheme { PrimaryTextButton(onClick = {}, text = "Text Button") } +} + +@Preview +@Composable +private fun PreviewTextButtonDisabled() { + AppTheme { PrimaryTextButton(onClick = {}, text = "Text Button", isEnabled = false) } +} + @Composable fun NegativeButton( onClick: () -> Unit, @@ -147,6 +164,46 @@ fun PrimaryButton( } @Composable +fun PrimaryTextButton( + onClick: () -> Unit, + text: String, + modifier: Modifier = Modifier, + colors: ButtonColors = + ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = Alpha20), + ), + textDecoration: TextDecoration = TextDecoration.None, + isEnabled: Boolean = true, + isLoading: Boolean = false, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, +) { + val hasIcon = leadingIcon != null || trailingIcon != null + TextButton( + onClick = onClick, + modifier = modifier.wrapContentHeight().width(IntrinsicSize.Max), + colors = colors, + enabled = isEnabled, + contentPadding = + if (hasIcon) { + PaddingValues(vertical = Dimens.buttonVerticalPadding) + } else { + ButtonDefaults.TextButtonContentPadding + }, + shape = MaterialTheme.shapes.small, + ) { + BaseButtonContent( + text = text, + textDecoration = textDecoration, + isLoading = isLoading, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) + } +} + +@Composable private fun BaseButton( onClick: () -> Unit, colors: ButtonColors, @@ -171,44 +228,58 @@ private fun BaseButton( modifier = modifier.wrapContentHeight().fillMaxWidth(), shape = MaterialTheme.shapes.small, ) { - // Used to center the text - when { - leadingIcon != null -> - Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { leadingIcon() } - trailingIcon != null -> - // Used to center the text - Box( - modifier = - Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible) - ) { - trailingIcon() - } - } - if (isLoading) { - MullvadCircularProgressIndicatorSmall() - } else { - Text( - text = text, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), - ) - } - when { - trailingIcon != null -> - Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { - trailingIcon() - } - leadingIcon != null -> - // Used to center the text - Box( - modifier = - Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible) - ) { - leadingIcon() - } - } + BaseButtonContent( + text = text, + isLoading = isLoading, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + ) + } +} + +@Composable +private fun RowScope.BaseButtonContent( + text: String, + isLoading: Boolean, + textDecoration: TextDecoration = TextDecoration.None, + leadingIcon: @Composable() (() -> Unit)?, + trailingIcon: @Composable() (() -> Unit)?, +) { + when { + leadingIcon != null -> + Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { leadingIcon() } + + trailingIcon != null -> + // Used to center the text + Box( + modifier = Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible) + ) { + trailingIcon() + } + } + if (isLoading) { + MullvadCircularProgressIndicatorSmall() + } else { + Text( + text = text, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.titleMedium, + textDecoration = textDecoration, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + when { + trailingIcon != null -> + Box(modifier = Modifier.padding(horizontal = Dimens.smallPadding)) { trailingIcon() } + + leadingIcon != null -> + // Used to center the text + Box( + modifier = Modifier.padding(horizontal = Dimens.smallPadding).alpha(AlphaInvisible) + ) { + leadingIcon() + } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt index 2e1bf30eab..1c843f0fcd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/CircularProgressIndicator.kt @@ -10,8 +10,10 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.Alpha20 @@ -38,7 +40,7 @@ fun MullvadCircularProgressIndicatorLarge( trackColor: Color = color.copy(alpha = Alpha20), ) { CircularProgressIndicator( - modifier.size(Dimens.circularProgressBarLargeSize), + modifier.size(Dimens.circularProgressBarLargeSize).testTag(CIRCULAR_PROGRESS_INDICATOR), color, Dimens.circularProgressBarLargeStrokeWidth, trackColor, @@ -53,7 +55,7 @@ fun MullvadCircularProgressIndicatorMedium( trackColor: Color = color.copy(alpha = Alpha20), ) { CircularProgressIndicator( - modifier.size(Dimens.circularProgressBarMediumSize), + modifier.size(Dimens.circularProgressBarMediumSize).testTag(CIRCULAR_PROGRESS_INDICATOR), color, Dimens.circularProgressBarMediumStrokeWidth, trackColor, @@ -68,7 +70,7 @@ fun MullvadCircularProgressIndicatorSmall( trackColor: Color = color.copy(alpha = Alpha20), ) { CircularProgressIndicator( - modifier.size(Dimens.circularProgressBarSmallSize), + modifier.size(Dimens.circularProgressBarSmallSize).testTag(CIRCULAR_PROGRESS_INDICATOR), color, Dimens.circularProgressBarSmallStrokeWidth, trackColor, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/DeviceListItem.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/DeviceListItem.kt new file mode 100644 index 0000000000..d8878fd0fc --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/DeviceListItem.kt @@ -0,0 +1,66 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.cell.TwoRowCell +import net.mullvad.mullvadvpn.lib.common.util.formatDate +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText +import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText + +@Composable +fun DeviceListItem( + device: Device, + isLoading: Boolean, + isCurrentDevice: Boolean = false, + onDeviceRemovalClicked: () -> Unit, +) { + TwoRowCell( + titleStyle = MaterialTheme.typography.listItemText, + titleColor = MaterialTheme.colorScheme.onPrimary, + subtitleStyle = MaterialTheme.typography.listItemSubText, + subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant, + titleText = device.displayName(), + subtitleText = stringResource(id = R.string.created_x, device.creationDate.formatDate()), + bodyView = { + if (isLoading) { + MullvadCircularProgressIndicatorMedium( + modifier = Modifier.padding(Dimens.smallPadding) + ) + } else if (isCurrentDevice) { + Text( + modifier = Modifier.padding(Dimens.smallPadding), + text = stringResource(R.string.current_device), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + IconButton(onClick = onDeviceRemovalClicked) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = stringResource(id = R.string.remove_button), + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(size = Dimens.deleteIconSize), + ) + } + } + }, + onCellClicked = null, + endPadding = Dimens.smallPadding, + minHeight = Dimens.cellHeight, + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt index b670f7d79e..04f6e92f6b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/CreateAccountConfirmationDialog.kt @@ -15,6 +15,7 @@ import com.ramcosta.composedestinations.result.EmptyResultBackNavigator import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -28,7 +29,7 @@ private fun PreviewCreateAccountConfirmationDialog() { @Composable @Destination<RootGraph>(style = DestinationStyle.Dialog::class) -fun CreateAccountConfirmation(navigator: ResultBackNavigator<Boolean>) { +fun CreateAccountConfirmation(navigator: ResultBackNavigator<Confirmed>) { InfoConfirmationDialog( navigator = navigator, titleType = InfoConfirmationDialogTitleType.IconOnly, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt index 13c3d04e77..aa8d6aa216 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DaitaDirectOnlyConfirmationDialog.kt @@ -13,6 +13,7 @@ import com.ramcosta.composedestinations.result.EmptyResultBackNavigator import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -25,7 +26,7 @@ private fun PreviewDaitaDirectOnlyConfirmationDialog() { @Destination<RootGraph>(style = DestinationStyle.Dialog::class) @Composable -fun DaitaDirectOnlyConfirmation(navigator: ResultBackNavigator<Boolean>) { +fun DaitaDirectOnlyConfirmation(navigator: ResultBackNavigator<Confirmed>) { InfoConfirmationDialog( navigator = navigator, titleType = InfoConfirmationDialogTitleType.IconOnly, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt index 643508792c..a7115ebc05 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/DiscardChangesDialog.kt @@ -9,6 +9,7 @@ import com.ramcosta.composedestinations.result.EmptyResultBackNavigator import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.spec.DestinationStyle import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -21,7 +22,7 @@ private fun PreviewDiscardChangesDialog() { @Destination<RootGraph>(style = DestinationStyle.Dialog::class) @Composable -fun DiscardChanges(resultBackNavigator: ResultBackNavigator<Boolean>) { +fun DiscardChanges(resultBackNavigator: ResultBackNavigator<Confirmed>) { InfoConfirmationDialog( navigator = resultBackNavigator, titleType = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ManageDevicesRemoveConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ManageDevicesRemoveConfirmationDialog.kt new file mode 100644 index 0000000000..8516860ca8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/ManageDevicesRemoveConfirmationDialog.kt @@ -0,0 +1,69 @@ +package net.mullvad.mullvadvpn.compose.dialog + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.result.EmptyResultBackNavigator +import com.ramcosta.composedestinations.result.ResultBackNavigator +import com.ramcosta.composedestinations.spec.DestinationStyle +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.textResource +import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialog +import net.mullvad.mullvadvpn.compose.dialog.info.InfoConfirmationDialogTitleType +import net.mullvad.mullvadvpn.compose.preview.DevicePreviewParameterProvider +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.util.appendTextWithStyledSubstring + +@Preview +@Composable +private fun PreviewManageDevicesRemoveConfirmationDialog( + @PreviewParameter(DevicePreviewParameterProvider::class) device: Device +) { + AppTheme { ManageDevicesRemoveConfirmation(EmptyResultBackNavigator(), device = device) } +} + +@Destination<RootGraph>(style = DestinationStyle.Dialog::class) +@Composable +fun ManageDevicesRemoveConfirmation(navigator: ResultBackNavigator<DeviceId>, device: Device) { + InfoConfirmationDialog( + navigator = navigator, + confirmValue = device.id, + titleType = InfoConfirmationDialogTitleType.IconOnly, + confirmButtonTitle = textResource(R.string.remove_button), + cancelButtonTitle = textResource(R.string.cancel), + ) { + Text( + text = device.descriptionText(), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + } +} + +@Composable +private fun Device.descriptionText(): AnnotatedString { + val line1 = + textResource(id = R.string.manage_devices_confirm_removal_description_line1, displayName()) + + val line2 = textResource(id = R.string.manage_devices_confirm_removal_description_line2) + + return buildAnnotatedString { + appendTextWithStyledSubstring( + text = line1, + substring = displayName(), + substringStyle = SpanStyle(color = Color.White), + ) + appendLine() + append(line2) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt index f351c95c0e..08ff7bcae0 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/SaveApiAccessMethodDialog.kt @@ -106,7 +106,9 @@ fun SaveApiAccessMethodDialog( ) } }, - title = { Text(text = state.text(), style = MaterialTheme.typography.headlineSmall) }, + title = { + Text(text = state.descriptionText(), style = MaterialTheme.typography.headlineSmall) + }, onDismissRequest = { /*Should not be able to dismiss*/ }, confirmButton = { PrimaryButton( @@ -134,7 +136,7 @@ fun SaveApiAccessMethodDialog( } @Composable -private fun SaveApiAccessMethodUiState.text() = +private fun SaveApiAccessMethodUiState.descriptionText() = stringResource( id = when (testingState) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt index ef3eb2b48b..0f314dc233 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoConfirmationDialog.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.dialog.info +import android.os.Parcelable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -20,6 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import com.ramcosta.composedestinations.result.EmptyResultBackNavigator import com.ramcosta.composedestinations.result.ResultBackNavigator +import kotlinx.parcelize.Parcelize import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar @@ -64,9 +66,30 @@ sealed interface InfoConfirmationDialogTitleType { data class IconAndTitle(val title: String) : InfoConfirmationDialogTitleType } +@Parcelize data object Confirmed : Parcelable + @Composable fun InfoConfirmationDialog( - navigator: ResultBackNavigator<Boolean>, + navigator: ResultBackNavigator<Confirmed>, + titleType: InfoConfirmationDialogTitleType, + confirmButtonTitle: String, + cancelButtonTitle: String, + content: @Composable (() -> Unit)? = null, +) { + InfoConfirmationDialog( + navigator = navigator, + confirmValue = Confirmed, + titleType = titleType, + confirmButtonTitle = confirmButtonTitle, + cancelButtonTitle = cancelButtonTitle, + content = content, + ) +} + +@Composable +fun <T> InfoConfirmationDialog( + navigator: ResultBackNavigator<T>, + confirmValue: T, titleType: InfoConfirmationDialogTitleType, confirmButtonTitle: String, cancelButtonTitle: String, @@ -87,7 +110,7 @@ fun InfoConfirmationDialog( } AlertDialog( - onDismissRequest = { navigator.navigateBack(false) }, + onDismissRequest = { navigator.navigateBack() }, title = if (title != null) { @Composable { Text(title) } @@ -126,17 +149,17 @@ fun InfoConfirmationDialog( null }, confirmButton = { - Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonSpacing)) { + Column(verticalArrangement = Arrangement.spacedBy(Dimens.buttonVerticalPadding)) { PrimaryButton( modifier = Modifier.fillMaxWidth(), text = confirmButtonTitle, - onClick = { navigator.navigateBack(true) }, + onClick = { navigator.navigateBack(confirmValue) }, ) PrimaryButton( modifier = Modifier.fillMaxWidth(), text = cancelButtonTitle, - onClick = { navigator.navigateBack(false) }, + onClick = { navigator.navigateBack() }, ) } }, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt index 82bf05ba64..138706930f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/DevicePreviewData.kt @@ -10,7 +10,7 @@ internal object DevicePreviewData { fun generateDevices(count: Int) = List(count) { index -> generateDevice(index) } .mapIndexed { index, device -> - DeviceItemUiState(device = device, isLoading = index == 0) + DeviceItemUiState(device = device, isLoading = index == 1) } fun generateDevice(index: Int = 0, id: String = UUID, name: String? = null) = diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt new file mode 100644 index 0000000000..41c9d14205 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ManageDevicesUiStatePreviewParameterProvider.kt @@ -0,0 +1,41 @@ +package net.mullvad.mullvadvpn.compose.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState +import net.mullvad.mullvadvpn.compose.state.ManageDevicesItemUiState +import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError +import net.mullvad.mullvadvpn.util.Lce + +class ManageDevicesUiStatePreviewParameterProvider : + PreviewParameterProvider<Lce<ManageDevicesUiState, GetDeviceListError>> { + override val values = + sequenceOf( + Lce.Content( + ManageDevicesUiState( + DevicePreviewData.generateDevices(NUMBER_OF_DEVICES_NORMAL) + .toManageDevicesState() + ) + ), + Lce.Content( + ManageDevicesUiState( + DevicePreviewData.generateDevices(NUMBER_OF_DEVICES_TOO_MANY) + .toManageDevicesState() + ) + ), + Lce.Content(ManageDevicesUiState(emptyList())), + Lce.Loading, + Lce.Error(GetDeviceListError.Unknown(IllegalStateException("Error"))), + ) + + private fun List<DeviceItemUiState>.toManageDevicesState() = mapIndexed { index, state -> + ManageDevicesItemUiState( + device = state.device, + isLoading = state.isLoading, + isCurrentDevice = index == 0, + ) + } +} + +private const val NUMBER_OF_DEVICES_NORMAL = 4 +private const val NUMBER_OF_DEVICES_TOO_MANY = 5 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt index b771853ad2..c077f72823 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/AccountScreen.kt @@ -8,11 +8,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info import androidx.compose.material3.ExperimentalMaterial3Api -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 @@ -23,6 +19,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -30,8 +27,8 @@ import androidx.lifecycle.compose.dropUnlessResumed import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.NavGraphs -import com.ramcosta.composedestinations.generated.destinations.DeviceNameInfoDestination import com.ramcosta.composedestinations.generated.destinations.LoginDestination +import com.ramcosta.composedestinations.generated.destinations.ManageDevicesDestination import com.ramcosta.composedestinations.generated.destinations.PaymentDestination import com.ramcosta.composedestinations.generated.destinations.RedeemVoucherDestination import com.ramcosta.composedestinations.generated.destinations.VerificationPendingDestination @@ -43,6 +40,7 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.ExternalButton import net.mullvad.mullvadvpn.compose.button.NegativeButton +import net.mullvad.mullvadvpn.compose.button.PrimaryTextButton import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton import net.mullvad.mullvadvpn.compose.component.CopyableObfuscationView import net.mullvad.mullvadvpn.compose.component.InformationView @@ -122,11 +120,14 @@ fun Account( state = state, snackbarHostState = snackbarHostState, onRedeemVoucherClick = dropUnlessResumed { navigator.navigate(RedeemVoucherDestination) }, + onManageDevicesClick = + dropUnlessResumed { + state.accountNumber?.let { navigator.navigate(ManageDevicesDestination(it)) } + }, onManageAccountClick = vm::onManageAccountClick, onLogoutClick = vm::onLogoutClick, onCopyAccountNumber = vm::onCopyAccountNumber, onBackClick = dropUnlessResumed { navigator.navigateUp() }, - navigateToDeviceInfo = dropUnlessResumed { navigator.navigate(DeviceNameInfoDestination) }, onPurchaseBillingProductClick = dropUnlessResumed { productId -> navigator.navigate(PaymentDestination(productId)) }, navigateToVerificationPendingDialog = @@ -142,9 +143,9 @@ fun AccountScreen( onCopyAccountNumber: (String) -> Unit, onRedeemVoucherClick: () -> Unit, onManageAccountClick: () -> Unit, + onManageDevicesClick: () -> Unit, onLogoutClick: () -> Unit, onPurchaseBillingProductClick: (productId: ProductId) -> Unit, - navigateToDeviceInfo: () -> Unit, navigateToVerificationPendingDialog: () -> Unit, onBackClick: () -> Unit, ) { @@ -170,7 +171,7 @@ fun AccountScreen( ) { DeviceNameRow( deviceName = state.deviceName ?: "", - onInfoClick = navigateToDeviceInfo, + onManageDevicesClick = onManageDevicesClick, ) AccountNumberRow( @@ -219,7 +220,7 @@ fun AccountScreen( } @Composable -private fun DeviceNameRow(deviceName: String, onInfoClick: () -> Unit) { +private fun DeviceNameRow(deviceName: String, onManageDevicesClick: () -> Unit) { Column(modifier = Modifier.fillMaxWidth()) { Text( style = MaterialTheme.typography.labelMedium, @@ -229,13 +230,12 @@ private fun DeviceNameRow(deviceName: String, onInfoClick: () -> Unit) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { InformationView(content = deviceName, whenMissing = MissingPolicy.SHOW_SPINNER) - IconButton(onClick = onInfoClick) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = stringResource(id = R.string.more_information), - tint = MaterialTheme.colorScheme.onSurface, - ) - } + Spacer(modifier = Modifier.weight(1f)) + PrimaryTextButton( + onClick = onManageDevicesClick, + text = stringResource(R.string.manage_devices), + textDecoration = TextDecoration.Underline, + ) } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt index f9422d8bcc..662a7a1316 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreen.kt @@ -97,7 +97,6 @@ import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.extensions.safeOpenUri import net.mullvad.mullvadvpn.compose.preview.ConnectUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.ConnectUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG @@ -425,8 +424,7 @@ private fun Content( ) } } - .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible) - .testTag(CIRCULAR_PROGRESS_INDICATOR), + .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible), ) Box( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt index 12d5bde055..27ff38d671 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListLocationsScreen.kt @@ -29,7 +29,6 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.generated.destinations.DiscardChangesDestination import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import com.ramcosta.composedestinations.result.NavResult import com.ramcosta.composedestinations.result.ResultBackNavigator import com.ramcosta.composedestinations.result.ResultRecipient import net.mullvad.mullvadvpn.R @@ -42,14 +41,15 @@ import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.constant.CommonContentKey import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem import net.mullvad.mullvadvpn.compose.preview.CustomListLocationUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.CustomListLocationsUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.SAVE_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.OnNavResultValue import net.mullvad.mullvadvpn.lib.model.CustomListId import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.theme.AppTheme @@ -78,20 +78,11 @@ data class CustomListLocationsNavArgs(val customListId: CustomListId, val newLis fun CustomListLocations( navigator: DestinationsNavigator, backNavigator: ResultBackNavigator<CustomListActionResultData>, - discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Boolean>, + discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Confirmed>, ) { val customListsViewModel = koinViewModel<CustomListLocationsViewModel>() - discardChangesResultRecipient.onNavResult { - when (it) { - NavResult.Canceled -> {} - is NavResult.Value -> { - if (it.value) { - backNavigator.navigateBack() - } - } - } - } + discardChangesResultRecipient.OnNavResultValue { backNavigator.navigateBack() } CollectSideEffectWithLifecycle(customListsViewModel.uiSideEffect) { sideEffect -> when (sideEffect) { @@ -209,9 +200,7 @@ private fun Actions(isSaveEnabled: Boolean, onSaveClick: () -> Unit) { private fun LazyListScope.loading() { item(key = CommonContentKey.PROGRESS, contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge( - modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) - ) + MullvadCircularProgressIndicatorLarge() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt index 0d2a47e026..f4a2cd186a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/CustomListsScreen.kt @@ -45,7 +45,6 @@ import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.extensions.itemsWithDivider import net.mullvad.mullvadvpn.compose.preview.CustomListsUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.CustomListsUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.NEW_LIST_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately @@ -156,11 +155,7 @@ fun CustomListsScreen( } private fun LazyListScope.loading() { - item(contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge( - modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) - ) - } + item(contentType = ContentType.PROGRESS) { MullvadCircularProgressIndicatorLarge() } } private fun LazyListScope.content( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt index ac95a6c0b6..230ac3a42c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DaitaScreen.kt @@ -46,6 +46,7 @@ import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.state.DaitaUiState import net.mullvad.mullvadvpn.compose.test.DAITA_SCREEN_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition @@ -78,16 +79,13 @@ private fun PreviewDaitaScreen() { fun SharedTransitionScope.Daita( navigator: DestinationsNavigator, animatedVisibilityScope: AnimatedVisibilityScope, - daitaConfirmationDialogResult: ResultRecipient<DaitaDirectOnlyConfirmationDestination, Boolean>, + daitaConfirmationDialogResult: + ResultRecipient<DaitaDirectOnlyConfirmationDestination, Confirmed>, ) { val viewModel = koinViewModel<DaitaViewModel>() val state by viewModel.uiState.collectAsStateWithLifecycle() - daitaConfirmationDialogResult.OnNavResultValue { - if (it) { - viewModel.setDirectOnly(true) - } - } + daitaConfirmationDialogResult.OnNavResultValue { viewModel.setDirectOnly(true) } DaitaScreen( state = state, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 0a9504f46c..fee2b70973 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -12,11 +12,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.HorizontalDivider -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 @@ -45,9 +41,8 @@ import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton -import net.mullvad.mullvadvpn.compose.cell.TwoRowCell +import net.mullvad.mullvadvpn.compose.component.DeviceListItem import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge -import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed @@ -56,15 +51,12 @@ import net.mullvad.mullvadvpn.compose.state.DeviceListUiState import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately -import net.mullvad.mullvadvpn.lib.common.util.formatDate import net.mullvad.mullvadvpn.lib.model.AccountNumber import net.mullvad.mullvadvpn.lib.model.Device import net.mullvad.mullvadvpn.lib.model.DeviceId import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.selected -import net.mullvad.mullvadvpn.lib.theme.typeface.listItemSubText -import net.mullvad.mullvadvpn.lib.theme.typeface.listItemText import net.mullvad.mullvadvpn.viewmodel.DeviceListSideEffect import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import org.koin.androidx.compose.koinViewModel @@ -298,37 +290,6 @@ private fun ColumnScope.DeviceListHeader(state: DeviceListUiState) { } @Composable -private fun DeviceListItem(device: Device, isLoading: Boolean, onDeviceRemovalClicked: () -> Unit) { - TwoRowCell( - titleStyle = MaterialTheme.typography.listItemText, - titleColor = MaterialTheme.colorScheme.onPrimary, - subtitleStyle = MaterialTheme.typography.listItemSubText, - subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant, - titleText = device.displayName(), - subtitleText = stringResource(id = R.string.created_x, device.creationDate.formatDate()), - bodyView = { - if (isLoading) { - MullvadCircularProgressIndicatorMedium( - modifier = Modifier.padding(Dimens.smallPadding) - ) - } else { - IconButton(onClick = onDeviceRemovalClicked) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = stringResource(id = R.string.remove_button), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(size = Dimens.deleteIconSize), - ) - } - } - }, - onCellClicked = null, - endPadding = Dimens.smallPadding, - minHeight = Dimens.cellHeight, - ) -} - -@Composable private fun DeviceListButtonPanel( state: DeviceListUiState, onContinueWithLogin: () -> Unit, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt index b9f2fb6dd3..bbc2a9ed04 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditApiAccessMethodScreen.kt @@ -49,6 +49,7 @@ import net.mullvad.mullvadvpn.compose.component.NavigateCloseIconButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithSmallTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.component.textResource +import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.preview.EditApiAccessMethodUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.ApiAccessMethodTypes import net.mullvad.mullvadvpn.compose.state.EditApiAccessFormData @@ -110,7 +111,7 @@ fun EditApiAccessMethod( navigator: DestinationsNavigator, backNavigator: ResultBackNavigator<Boolean>, saveApiAccessMethodResultRecipient: ResultRecipient<SaveApiAccessMethodDestination, Boolean>, - discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Boolean>, + discardChangesResultRecipient: ResultRecipient<DiscardChangesDestination, Confirmed>, ) { val viewModel = koinViewModel<EditApiAccessMethodViewModel>() @@ -160,11 +161,7 @@ fun EditApiAccessMethod( } } - discardChangesResultRecipient.OnNavResultValue { discardChanges -> - if (discardChanges) { - navigator.navigateUp() - } - } + discardChangesResultRecipient.OnNavResultValue { navigator.navigateUp() } val state by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt index 513bc636d4..32bdf4703b 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/EditCustomListScreen.kt @@ -44,7 +44,6 @@ import net.mullvad.mullvadvpn.compose.component.SpacedColumn import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed import net.mullvad.mullvadvpn.compose.preview.EditCustomListUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.EditCustomListUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.test.DELETE_DROPDOWN_MENU_ITEM_TEST_TAG import net.mullvad.mullvadvpn.compose.test.TOP_BAR_DROPDOWN_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition @@ -139,9 +138,7 @@ fun EditCustomListScreen( SpacedColumn(modifier = modifier, alignment = Alignment.Top) { when (state) { EditCustomListUiState.Loading -> { - MullvadCircularProgressIndicatorLarge( - modifier = Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR) - ) + MullvadCircularProgressIndicatorLarge() } EditCustomListUiState.NotFound -> { Text( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt index fef29516c5..b4eea1f277 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/LoginScreen.kt @@ -68,6 +68,7 @@ import net.mullvad.mullvadvpn.compose.button.PrimaryButton import net.mullvad.mullvadvpn.compose.button.VariantButton import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.dialog.info.Confirmed import net.mullvad.mullvadvpn.compose.preview.LoginUiStatePreviewParameterProvider import net.mullvad.mullvadvpn.compose.state.LoginError import net.mullvad.mullvadvpn.compose.state.LoginState @@ -108,7 +109,7 @@ fun Login( accountNumber: String? = null, vm: LoginViewModel = koinViewModel(), createAccountConfirmationDialogResult: - ResultRecipient<CreateAccountConfirmationDestination, Boolean>, + ResultRecipient<CreateAccountConfirmationDestination, Confirmed>, ) { val state by vm.uiState.collectAsStateWithLifecycle() @@ -120,11 +121,7 @@ fun Login( } } - createAccountConfirmationDialogResult.OnNavResultValue { createAccount -> - if (createAccount) { - vm.onCreateAccountConfirmed() - } - } + createAccountConfirmationDialogResult.OnNavResultValue { vm.onCreateAccountConfirmed() } val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt new file mode 100644 index 0000000000..d6a4e38256 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/ManageDevicesScreen.kt @@ -0,0 +1,187 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.dropUnlessResumed +import com.ramcosta.composedestinations.annotation.Destination +import com.ramcosta.composedestinations.annotation.RootGraph +import com.ramcosta.composedestinations.generated.destinations.ManageDevicesRemoveConfirmationDestination +import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.ResultRecipient +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.PrimaryButton +import net.mullvad.mullvadvpn.compose.cell.BaseSubtitleCell +import net.mullvad.mullvadvpn.compose.component.DeviceListItem +import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorMedium +import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithMediumTopBar +import net.mullvad.mullvadvpn.compose.extensions.dropUnlessResumed +import net.mullvad.mullvadvpn.compose.preview.ManageDevicesUiStatePreviewParameterProvider +import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState +import net.mullvad.mullvadvpn.compose.transitions.DefaultTransition +import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle +import net.mullvad.mullvadvpn.compose.util.OnNavResultValue +import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.util.Lce +import net.mullvad.mullvadvpn.viewmodel.ManageDevicesSideEffect +import net.mullvad.mullvadvpn.viewmodel.ManageDevicesViewModel +import org.koin.androidx.compose.koinViewModel + +@Composable +@Preview("Normal|TooMany|Empty|Loading|Error") +private fun PreviewDeviceListScreenContent( + @PreviewParameter(ManageDevicesUiStatePreviewParameterProvider::class) + state: Lce<ManageDevicesUiState, GetDeviceListError> +) { + AppTheme { ManageDevicesScreen(state = state, SnackbarHostState(), {}, {}, {}) } +} + +private typealias StateLce = Lce<ManageDevicesUiState, GetDeviceListError> + +@Destination<RootGraph>(style = DefaultTransition::class, navArgs = DeviceListNavArgs::class) +@Composable +fun ManageDevices( + navigator: DestinationsNavigator, + confirmRemoveResultRecipient: + ResultRecipient<ManageDevicesRemoveConfirmationDestination, DeviceId>, +) { + val viewModel = koinViewModel<ManageDevicesViewModel>() + val state by viewModel.uiState.collectAsStateWithLifecycle() + + confirmRemoveResultRecipient.OnNavResultValue { deviceId -> + viewModel.removeDevice(deviceIdToRemove = deviceId) + } + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + CollectSideEffectWithLifecycle( + viewModel.uiSideEffect, + minActiveState = Lifecycle.State.RESUMED, + ) { sideEffect -> + when (sideEffect) { + ManageDevicesSideEffect.FailedToRemoveDevice -> { + launch { + snackbarHostState.showSnackbarImmediately( + message = context.getString(R.string.failed_to_remove_device) + ) + } + } + } + } + + ManageDevicesScreen( + state = state, + snackbarHostState = snackbarHostState, + onBackClick = dropUnlessResumed { navigator.navigateUp() }, + onTryAgainClicked = viewModel::fetchDevices, + navigateToRemoveDeviceConfirmationDialog = + dropUnlessResumed<Device> { + navigator.navigate(ManageDevicesRemoveConfirmationDestination(it)) + }, + ) +} + +@Composable +fun ManageDevicesScreen( + state: StateLce, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, + onBackClick: () -> Unit, + onTryAgainClicked: () -> Unit, + navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit, +) { + ScaffoldWithMediumTopBar( + appBarTitle = stringResource(id = R.string.manage_devices), + navigationIcon = { NavigateBackIconButton(onNavigateBack = onBackClick) }, + snackbarHostState = snackbarHostState, + ) { modifier -> + when (state) { + is Lce.Content -> + Content(modifier, state.value, navigateToRemoveDeviceConfirmationDialog) + is Lce.Error -> Error(modifier, onTryAgainClicked) + Lce.Loading -> Loading(modifier) + } + } +} + +@Composable +private fun Content( + modifier: Modifier, + state: ManageDevicesUiState, + navigateToRemoveDeviceConfirmationDialog: (device: Device) -> Unit, +) { + Column(modifier) { + BaseSubtitleCell( + text = stringResource(R.string.manage_devices_description), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + ManageDevicesItems( + state = state, + navigateToRemoveDeviceConfirmationDialog = navigateToRemoveDeviceConfirmationDialog, + ) + } +} + +@Composable +private fun Error(modifier: Modifier, tryAgain: () -> Unit) { + Column(modifier, verticalArrangement = Arrangement.Center) { + Text( + text = stringResource(id = R.string.failed_to_fetch_devices), + modifier = Modifier.padding(Dimens.smallPadding).align(Alignment.CenterHorizontally), + ) + PrimaryButton( + onClick = tryAgain, + text = stringResource(id = R.string.try_again), + modifier = + Modifier.padding( + top = Dimens.buttonSpacing, + start = Dimens.sideMargin, + end = Dimens.sideMargin, + ), + ) + } +} + +@Composable +private fun Loading(modifier: Modifier) { + Box(modifier, contentAlignment = Alignment.Center) { + MullvadCircularProgressIndicatorMedium(modifier = Modifier.padding(Dimens.smallPadding)) + } +} + +@Composable +private fun ManageDevicesItems( + state: ManageDevicesUiState, + navigateToRemoveDeviceConfirmationDialog: (Device) -> Unit, +) { + state.devices.forEachIndexed { index, (device, loading, isCurrentDevice) -> + DeviceListItem(device = device, isLoading = loading, isCurrentDevice = isCurrentDevice) { + navigateToRemoveDeviceConfirmationDialog(device) + } + if (state.devices.lastIndex != index) { + HorizontalDivider() + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt index d08a902ae3..33113b03c9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/location/SelectLocationList.kt @@ -15,7 +15,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.text.style.TextAlign @@ -30,7 +29,6 @@ import net.mullvad.mullvadvpn.compose.extensions.animateScrollAndCentralizeItem import net.mullvad.mullvadvpn.compose.state.RelayListItem import net.mullvad.mullvadvpn.compose.state.RelayListType import net.mullvad.mullvadvpn.compose.state.SelectLocationListUiState -import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange import net.mullvad.mullvadvpn.lib.model.RelayItem import net.mullvad.mullvadvpn.lib.theme.Dimens @@ -99,9 +97,7 @@ fun SelectLocationList( } private fun LazyListScope.loading() { - item(contentType = ContentType.PROGRESS) { - MullvadCircularProgressIndicatorLarge(Modifier.testTag(CIRCULAR_PROGRESS_INDICATOR)) - } + item(contentType = ContentType.PROGRESS) { MullvadCircularProgressIndicatorLarge() } } private fun LazyListScope.entryBlocked(openDaitaSettings: () -> Unit) { diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ManageDevicesUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ManageDevicesUiState.kt new file mode 100644 index 0000000000..ce57cf46b5 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ManageDevicesUiState.kt @@ -0,0 +1,11 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.lib.model.Device + +data class ManageDevicesUiState(val devices: List<ManageDevicesItemUiState>) + +data class ManageDevicesItemUiState( + val device: Device, + val isLoading: Boolean, + val isCurrentDevice: Boolean, +) 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 0e86fa0f55..776a36ccb7 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 @@ -82,6 +82,7 @@ import net.mullvad.mullvadvpn.viewmodel.EditCustomListNameDialogViewModel import net.mullvad.mullvadvpn.viewmodel.EditCustomListViewModel import net.mullvad.mullvadvpn.viewmodel.FilterViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel +import net.mullvad.mullvadvpn.viewmodel.ManageDevicesViewModel import net.mullvad.mullvadvpn.viewmodel.MtuDialogViewModel import net.mullvad.mullvadvpn.viewmodel.MullvadAppViewModel import net.mullvad.mullvadvpn.viewmodel.MultihopViewModel @@ -223,6 +224,7 @@ val uiModule = module { ) } viewModel { DeviceListViewModel(get(), get()) } + viewModel { ManageDevicesViewModel(get(), get()) } viewModel { DeviceRevokedViewModel(get(), get()) } viewModel { MtuDialogViewModel(get(), get()) } viewModel { DnsDialogViewModel(get(), get(), get(), get()) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt index 8be59ef814..c16aa9242f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/LoadingContentError.kt @@ -7,21 +7,19 @@ sealed interface Lce<out T, out E> { data class Error<E>(val error: E) : Lce<Nothing, E> - fun content(): T? = + fun contentOrNull(): T? = when (this) { is Loading, is Error -> null is Content -> value } - fun error(): E? = + fun errorOrNull(): E? = when (this) { is Loading, is Content -> null is Error -> error } - - fun isLoading(): Boolean = this is Loading } fun <T, E> T.toLce(): Lce<T, E> = Lce.Content(this) @@ -31,13 +29,11 @@ sealed interface Lc<out T> { data class Content<T>(val value: T) : Lc<T> - fun content(): T? = + fun contentOrNull(): T? = when (this) { is Content -> value Loading -> null } - - fun isLoading(): Boolean = this is Loading } fun <T> T.toLc(): Lc<T> = Lc.Content(this) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt index adc64ec41e..a1cc49811a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt @@ -1,6 +1,9 @@ package net.mullvad.mullvadvpn.util import android.text.Html +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.withStyle import androidx.core.text.HtmlCompat fun String.appendHideNavOnPlayBuild(isPlayBuild: Boolean): String = @@ -14,3 +17,23 @@ fun String.removeHtmlTags(): String = Html.fromHtml(this, HtmlCompat.FROM_HTML_MODE_LEGACY).toString() fun List<String>.trimAll() = map { it.trim() } + +/** + * Appends `text` and styles occurrences of `substring` in `text` with the given `substringStyle`. + */ +fun AnnotatedString.Builder.appendTextWithStyledSubstring( + text: String, + substring: String, + substringStyle: SpanStyle, + ignoreCase: Boolean = false, + limit: Int = 0, +) { + val parts = text.split(substring, ignoreCase = ignoreCase, limit = limit) + + parts.forEachIndexed { index, part -> + append(part) + if (index != parts.lastIndex) { + withStyle(substringStyle) { append(substring) } + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt new file mode 100644 index 0000000000..9e8dfde0a8 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModel.kt @@ -0,0 +1,84 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import net.mullvad.mullvadvpn.compose.state.DeviceItemUiState +import net.mullvad.mullvadvpn.compose.state.DeviceListUiState +import net.mullvad.mullvadvpn.compose.state.ManageDevicesItemUiState +import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.util.Lce +import net.mullvad.mullvadvpn.util.toLce + +class ManageDevicesViewModel( + deviceRepository: DeviceRepository, + private val deviceListViewModel: DeviceListViewModel, +) : ViewModel() { + + val uiSideEffect = + deviceListViewModel.uiSideEffect + .filter { it is DeviceListSideEffect.FailedToRemoveDevice } + .map { ManageDevicesSideEffect.FailedToRemoveDevice } + + val uiState: StateFlow<Lce<ManageDevicesUiState, GetDeviceListError>> = + combine( + deviceRepository.deviceState.filterIsInstance<DeviceState.LoggedIn>(), + deviceListViewModel.uiState, + ) { loggedInState, deviceListState -> + when (deviceListState) { + DeviceListUiState.Loading -> Lce.Loading + is DeviceListUiState.Error -> Lce.Error(deviceListState.error) + is DeviceListUiState.Content -> { + ManageDevicesUiState( + deviceListState.devices.toManageDevicesItemUiState( + currentDevice = loggedInState.device + ) + ) + .toLce() + } + } + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Lce.Loading) + + fun fetchDevices() = deviceListViewModel.fetchDevices() + + fun removeDevice(deviceIdToRemove: DeviceId) = + deviceListViewModel.removeDevice(deviceIdToRemove) + + private fun List<DeviceItemUiState>.toManageDevicesItemUiState( + currentDevice: Device + ): List<ManageDevicesItemUiState> { + // Put the current device first in the list, but otherwise keep the sort order. + val devices = toMutableList() + devices + .indexOfFirst { it.device == currentDevice } + .let { index -> + if (index > 0) { + devices.add(0, devices.removeAt(index)) + } + } + + return devices.map { + ManageDevicesItemUiState( + device = it.device, + isLoading = it.isLoading, + isCurrentDevice = it.device == currentDevice, + ) + } + } +} + +sealed interface ManageDevicesSideEffect { + data object FailedToRemoveDevice : ManageDevicesSideEffect +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/utils/AppendTextWithStyledSubstringTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/utils/AppendTextWithStyledSubstringTest.kt new file mode 100644 index 0000000000..90024be351 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/utils/AppendTextWithStyledSubstringTest.kt @@ -0,0 +1,96 @@ +package net.mullvad.mullvadvpn.utils + +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import kotlin.test.assertEquals +import net.mullvad.mullvadvpn.util.appendTextWithStyledSubstring +import org.junit.jupiter.api.Test + +class AppendTextWithStyledSubstringTest { + @Test + fun `empty input should result in empty output`() { + + val output = buildAnnotatedString { + appendTextWithStyledSubstring( + text = "", + substring = "abc", + substringStyle = SpanStyle(), + ) + } + + assertEquals("", output.text) + } + + @Test + fun `split only should result in split only`() { + + val split = "abc" + + val output = buildAnnotatedString { + appendTextWithStyledSubstring( + text = split, + substring = split, + substringStyle = SpanStyle(), + ) + } + + assertEquals(split, output.text) + } + + @Test + fun `split twice should result in split twice`() { + + val split = "abcabc" + + val output = buildAnnotatedString { + appendTextWithStyledSubstring( + text = split, + substring = split, + substringStyle = SpanStyle(), + ) + } + + assertEquals(split, output.text) + } + + @Test + fun `split anywhere should return the input text`() { + + val text = "abca longer abc string to split abc" + val split = "abc" + + val output = buildAnnotatedString { + appendTextWithStyledSubstring( + text = text, + substring = split, + substringStyle = SpanStyle(), + ) + } + + assertEquals(text, output.text) + } + + @Test + fun `span styles should be applied to all matching substrings`() { + + val text = "Cool Cat: your username is Cool Cat." + val split = "Cool Cat" + + val output = buildAnnotatedString { + appendTextWithStyledSubstring( + text = text, + substring = split, + substringStyle = SpanStyle(), + ) + } + + assertEquals(text, output.text) + assertEquals(2, output.spanStyles.size) + + assertEquals(0, output.spanStyles[0].start) + assertEquals(8, output.spanStyles[0].end) + + assertEquals(27, output.spanStyles[1].start) + assertEquals(35, output.spanStyles[1].end) + } +} diff --git a/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt new file mode 100644 index 0000000000..ac5446cc07 --- /dev/null +++ b/android/app/src/test/kotlin/net/mullvad/mullvadvpn/viewmodel/ManageDevicesViewModelTest.kt @@ -0,0 +1,147 @@ +package net.mullvad.mullvadvpn.viewmodel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import app.cash.turbine.test +import arrow.core.left +import arrow.core.right +import com.ramcosta.composedestinations.generated.navargs.toSavedStateHandle +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import java.time.ZonedDateTime +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import net.mullvad.mullvadvpn.compose.screen.DeviceListNavArgs +import net.mullvad.mullvadvpn.compose.state.ManageDevicesUiState +import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule +import net.mullvad.mullvadvpn.lib.model.AccountNumber +import net.mullvad.mullvadvpn.lib.model.Device +import net.mullvad.mullvadvpn.lib.model.DeviceId +import net.mullvad.mullvadvpn.lib.model.DeviceState +import net.mullvad.mullvadvpn.lib.model.GetDeviceListError +import net.mullvad.mullvadvpn.lib.shared.DeviceRepository +import net.mullvad.mullvadvpn.util.Lce +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExperimentalCoroutinesApi +@ExtendWith(TestCoroutineRule::class) +class ManageDevicesViewModelTest { + + private val mockDeviceRepository: DeviceRepository = mockk() + private val mockSavedStateHandle: SavedStateHandle = mockk(relaxed = true) + + private lateinit var viewModel: ManageDevicesViewModel + + @BeforeEach + fun setup() { + // Mock SavedStateHandle to return the account number + every { mockSavedStateHandle.get<AccountNumber>("accountNumber") } returns testAccountNumber + + // Mock successful device list fetch by default + coEvery { mockDeviceRepository.deviceList(testAccountNumber) } returns + testDeviceList.right() + + every { mockDeviceRepository.deviceState } returns MutableStateFlow(deviceState) + + viewModel = + ManageDevicesViewModel( + deviceRepository = mockDeviceRepository, + deviceListViewModel = + DeviceListViewModel( + deviceRepository = mockDeviceRepository, + dispatcher = UnconfinedTestDispatcher(), + savedStateHandle = + DeviceListNavArgs(accountNumber = testAccountNumber) + .toSavedStateHandle(), + ), + ) + } + + @AfterEach + fun tearDown() { + viewModel.viewModelScope.coroutineContext.cancel() + unmockkAll() + } + + @Test + fun `initial state should be Loading followed by Content`() = runTest { + // Initial state is Loading + assertIs<Lce.Loading>(viewModel.uiState.value) + + viewModel.uiState.test { + val contentState = awaitItem() + assertIs<Lce.Content<ManageDevicesUiState>>(contentState) + assertEquals(3, contentState.value.devices.size) + } + } + + @Test + fun `fetchDevices should update state to Error on failure`() = runTest { + val error = GetDeviceListError.Unknown(RuntimeException("Network failed")) + coEvery { mockDeviceRepository.deviceList(testAccountNumber) } returns error.left() + + viewModel.uiState.test { + val errorState = awaitItem() + assertIs<Lce.Error<GetDeviceListError>>(errorState) + assertEquals(error, errorState.error) + } + } + + @Test + fun `the logged in device should appear first in the list`() = runTest { + viewModel.uiState.test { + val contentState = awaitItem() + assertIs<Lce.Content<ManageDevicesUiState>>(contentState) + + val devices = contentState.value.devices + assertEquals(testDeviceId2, devices[0].device.id) + assertTrue(devices[0].isCurrentDevice) + assertFalse(devices[1].isCurrentDevice) + assertFalse(devices[2].isCurrentDevice) + } + } + + companion object { + private val testAccountNumber = AccountNumber("1234567890123456") + private val testDeviceId1 = DeviceId.fromString("12345678-1234-5678-1234-567812345678") + private val testDeviceId2 = DeviceId.fromString("87654321-1234-5678-1234-567812345678") + private val testDeviceId3 = DeviceId.fromString("87654321-4321-5678-1234-567812345678") + + private val testDevice1 = + Device( + id = testDeviceId1, + name = "Device 1", + creationDate = ZonedDateTime.now().minusSeconds(100), + ) + + private val testDevice2 = + Device( + id = testDeviceId2, + name = "Device 2", + creationDate = ZonedDateTime.now().minusSeconds(200), + ) + + private val testDevice3 = + Device( + id = testDeviceId3, + name = "Device 3", + creationDate = ZonedDateTime.now().minusSeconds(300), + ) + private val testDeviceList = listOf(testDevice1, testDevice2, testDevice3) + + private val deviceState = + DeviceState.LoggedIn(accountNumber = testAccountNumber, device = testDevice2) + } +} diff --git a/android/lib/resource/src/main/res/values/strings.xml b/android/lib/resource/src/main/res/values/strings.xml index 6c7f7da319..401e57e566 100644 --- a/android/lib/resource/src/main/res/values/strings.xml +++ b/android/lib/resource/src/main/res/values/strings.xml @@ -148,6 +148,10 @@ <string name="max_devices_confirm_removal_description"> <![CDATA[Are you sure you want to log <b>%s</b> out?]]> </string> + <string name="manage_devices">Manage devices</string> + <string name="manage_devices_confirm_removal_description_line1">Remove %s?</string> + <string name="manage_devices_confirm_removal_description_line2">The device will be removed from the list and logged out.</string> + <string name="manage_devices_description">View and manage all your logged in devices. You can have up to 5 devices on one account at a time. Each device gets a name when logged in to help you tell them apart easily.</string> <string name="confirm_removal">Yes, log out device</string> <string name="continue_login">Continue with login</string> <string name="failed_to_fetch_devices">Failed to fetch list of devices</string> @@ -305,6 +309,7 @@ <string name="add">Add</string> <string name="api_access_description">Manage and add custom methods to access the Mullvad API.</string> <string name="current_method">Current: %s</string> + <string name="current_device">Current device</string> <string name="api_access_method_info_first_line">The app needs to communicate with a Mullvad API server to log you in, fetch server lists, and other critical operations.</string> <string name="api_access_method_info_second_line">On some networks, where various types of censorship are being used, the API servers might not be directly reachable.</string> <string name="api_access_method_info_third_line">This feature allows you to circumvent that censorship by adding custom ways to access the API via proxies and similar methods.</string> |
