diff --git a/CHANGELOG.md b/CHANGELOG.md index 404f86d70af8..c76f35844a64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Line wrap the file at 100 chars. Th - Migrate welcome view to compose. - Migrate in app notifications to compose. - Move out of time evaluation to connect view model. +- Migrate out of time view to compose. #### Linux - Don't block forwarding of traffic when the split tunnel mark (ct mark) is set. diff --git a/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt new file mode 100644 index 000000000000..a97e587c8c92 --- /dev/null +++ b/android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreenTest.kt @@ -0,0 +1,172 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import io.mockk.MockKAnnotations +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class OutOfTimeScreenTest { + @get:Rule val composeTestRule = createComposeRule() + + @Before + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun testDisableSitePayment() { + // Arrange + composeTestRule.setContent { + OutOfTimeScreen( + showSitePayment = false, + uiState = OutOfTimeUiState(), + viewActions = MutableSharedFlow(), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {}, + onDisconnectClick = {} + ) + } + + // Assert + composeTestRule.apply { + onNodeWithText( + "Either buy credit on our website or redeem a voucher.", + substring = true + ) + .assertDoesNotExist() + onNodeWithText("Buy credit").assertDoesNotExist() + } + } + + @Test + fun testOpenAccountView() { + // Arrange + composeTestRule.setContent { + OutOfTimeScreen( + showSitePayment = true, + uiState = OutOfTimeUiState(), + viewActions = + MutableStateFlow(OutOfTimeViewModel.ViewAction.OpenAccountView("222")), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {}, + onDisconnectClick = {} + ) + } + + // Assert + composeTestRule.apply { onNodeWithText("Congrats!").assertDoesNotExist() } + } + + @Test + fun testOpenConnectScreen() { + // Arrange + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + OutOfTimeScreen( + showSitePayment = true, + uiState = OutOfTimeUiState(), + viewActions = MutableStateFlow(OutOfTimeViewModel.ViewAction.OpenConnectScreen), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = mockClickListener, + onDisconnectClick = {} + ) + } + + // Assert + verify(exactly = 1) { mockClickListener.invoke() } + } + + @Test + fun testClickSitePaymentButton() { + // Arrange + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + OutOfTimeScreen( + showSitePayment = true, + uiState = OutOfTimeUiState(), + viewActions = MutableSharedFlow(), + onSitePaymentClick = mockClickListener, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {}, + onDisconnectClick = {} + ) + } + + // Act + composeTestRule.apply { onNodeWithText("Buy credit").performClick() } + + // Assert + verify(exactly = 1) { mockClickListener.invoke() } + } + + @Test + fun testClickRedeemVoucher() { + // Arrange + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + OutOfTimeScreen( + showSitePayment = true, + uiState = OutOfTimeUiState(), + viewActions = MutableSharedFlow(), + onSitePaymentClick = {}, + onRedeemVoucherClick = mockClickListener, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {}, + onDisconnectClick = {} + ) + } + + // Act + composeTestRule.apply { onNodeWithText("Redeem voucher").performClick() } + + // Assert + verify(exactly = 1) { mockClickListener.invoke() } + } + + @Test + fun testClickDisconnect() { + // Arrange + val mockClickListener: () -> Unit = mockk(relaxed = true) + composeTestRule.setContent { + OutOfTimeScreen( + showSitePayment = true, + uiState = OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null)), + viewActions = MutableSharedFlow(), + onSitePaymentClick = {}, + onRedeemVoucherClick = {}, + onSettingsClick = {}, + onAccountClick = {}, + openConnectScreen = {}, + onDisconnectClick = mockClickListener + ) + } + + // Act + composeTestRule.apply { onNodeWithText("Disconnect").performClick() } + + // Assert + verify(exactly = 1) { mockClickListener.invoke() } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ExternalActionButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ExternalActionButton.kt new file mode 100644 index 000000000000..d3945f70698b --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/ExternalActionButton.kt @@ -0,0 +1,70 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.constraintlayout.compose.ConstraintLayout +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.AlphaVisible +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewExternalActionButton() { + AppTheme { + ExternalActionButton(onClick = {}, colors = ButtonDefaults.buttonColors(), text = "Button") + } +} + +@Composable +fun ExternalActionButton( + onClick: () -> Unit, + colors: ButtonColors, + text: String, + modifier: Modifier = Modifier, + isEnabled: Boolean = true, +) { + ActionButton( + onClick = onClick, + colors = colors, + modifier = modifier, + isEnabled = isEnabled, + ) { + ConstraintLayout(modifier = Modifier.fillMaxSize()) { + val (title, logo) = createRefs() + Text( + text = text, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier.constrainAs(title) { + end.linkTo(logo.start) + centerTo(parent) + } + ) + Image( + painter = painterResource(id = R.drawable.icon_extlink), + contentDescription = null, + modifier = + Modifier.constrainAs(logo) { + centerVerticallyTo(parent) + end.linkTo(parent.end) + } + .padding(horizontal = Dimens.smallPadding) + .alpha(if (isEnabled) AlphaVisible else AlphaDisabled) + ) + } + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/RedeemVoucherButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/RedeemVoucherButton.kt new file mode 100644 index 000000000000..41ab2cf87694 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/RedeemVoucherButton.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.SpacedColumn +import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.AlphaInactive +import net.mullvad.mullvadvpn.lib.theme.AppTheme + +@Preview +@Composable +private fun PreviewRedeemVoucherButton() { + AppTheme { + SpacedColumn { + RedeemVoucherButton(onClick = {}, isEnabled = true) + RedeemVoucherButton(onClick = {}, isEnabled = false) + } + } +} + +@Composable +fun RedeemVoucherButton( + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.background, + onClick: () -> Unit, + isEnabled: Boolean +) { + ActionButton( + text = stringResource(id = R.string.redeem_voucher), + onClick = onClick, + modifier = modifier, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface, + disabledContentColor = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaInactive) + .compositeOver(background), + disabledContainerColor = + MaterialTheme.colorScheme.surface + .copy(alpha = AlphaDisabled) + .compositeOver(background) + ), + isEnabled = isEnabled + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt new file mode 100644 index 000000000000..bc82bca29cf9 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/button/SitePaymentButton.kt @@ -0,0 +1,59 @@ +package net.mullvad.mullvadvpn.compose.button + +import androidx.compose.foundation.background +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.component.SpacedColumn +import net.mullvad.mullvadvpn.lib.theme.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.AlphaInactive +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewSitePaymentButton() { + AppTheme { + SpacedColumn( + spacing = Dimens.cellVerticalSpacing, + modifier = Modifier.background(color = MaterialTheme.colorScheme.background) + ) { + SitePaymentButton(onClick = {}, isEnabled = true) + SitePaymentButton(onClick = {}, isEnabled = false) + } + } +} + +@Composable +fun SitePaymentButton( + onClick: () -> Unit, + isEnabled: Boolean, + modifier: Modifier = Modifier, + background: Color = MaterialTheme.colorScheme.background, +) { + ExternalActionButton( + onClick = onClick, + modifier = modifier, + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface, + disabledContentColor = + MaterialTheme.colorScheme.onPrimary + .copy(alpha = AlphaInactive) + .compositeOver(background), + disabledContainerColor = + MaterialTheme.colorScheme.surface + .copy(alpha = AlphaDisabled) + .compositeOver(background) + ), + isEnabled = isEnabled, + text = stringResource(id = R.string.buy_credit) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt new file mode 100644 index 000000000000..e85939c51cb2 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt @@ -0,0 +1,12 @@ +package net.mullvad.mullvadvpn.compose.extensions + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.res.stringResource +import net.mullvad.mullvadvpn.R + +@Composable +fun UriHandler.createOpenAccountPageHook(): (String) -> Unit { + val accountUrl = stringResource(id = R.string.account_url) + return { token -> this.openUri("$accountUrl?token=$token") } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt new file mode 100644 index 000000000000..a9ab126dae42 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -0,0 +1,233 @@ +package net.mullvad.mullvadvpn.compose.screen + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton +import net.mullvad.mullvadvpn.compose.button.SitePaymentButton +import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar +import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar +import net.mullvad.mullvadvpn.compose.extensions.createOpenAccountPageHook +import net.mullvad.mullvadvpn.compose.state.OutOfTimeUiState +import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel +import net.mullvad.talpid.tunnel.ActionAfterDisconnect +import net.mullvad.talpid.tunnel.ErrorState +import net.mullvad.talpid.tunnel.ErrorStateCause + +@Preview +@Composable +private fun PreviewOutOfTimeScreenDisconnected() { + AppTheme { + OutOfTimeScreen( + showSitePayment = true, + uiState = OutOfTimeUiState(tunnelState = TunnelState.Disconnected), + viewActions = MutableSharedFlow().asSharedFlow() + ) + } +} + +@Preview +@Composable +private fun PreviewOutOfTimeScreenConnecting() { + AppTheme { + OutOfTimeScreen( + showSitePayment = true, + uiState = OutOfTimeUiState(tunnelState = TunnelState.Connecting(null, null)), + viewActions = MutableSharedFlow().asSharedFlow() + ) + } +} + +@Preview +@Composable +private fun PreviewOutOfTimeScreenError() { + AppTheme { + OutOfTimeScreen( + showSitePayment = true, + uiState = + OutOfTimeUiState( + tunnelState = + TunnelState.Error( + ErrorState(cause = ErrorStateCause.IsOffline, isBlocking = true) + ) + ), + viewActions = MutableSharedFlow().asSharedFlow() + ) + } +} + +@Composable +fun OutOfTimeScreen( + showSitePayment: Boolean, + uiState: OutOfTimeUiState, + viewActions: SharedFlow, + onDisconnectClick: () -> Unit = {}, + onSitePaymentClick: () -> Unit = {}, + onRedeemVoucherClick: () -> Unit = {}, + openConnectScreen: () -> Unit = {}, + onSettingsClick: () -> Unit = {}, + onAccountClick: () -> Unit = {} +) { + val openAccountPage = LocalUriHandler.current.createOpenAccountPageHook() + LaunchedEffect(key1 = Unit) { + viewActions.collect { viewAction -> + when (viewAction) { + is OutOfTimeViewModel.ViewAction.OpenAccountView -> + openAccountPage(viewAction.token) + OutOfTimeViewModel.ViewAction.OpenConnectScreen -> openConnectScreen() + } + } + } + val scrollState = rememberScrollState() + ScaffoldWithTopBar( + topBarColor = + if (uiState.tunnelState.isSecured()) { + MaterialTheme.colorScheme.inversePrimary + } else { + MaterialTheme.colorScheme.error + }, + statusBarColor = + if (uiState.tunnelState.isSecured()) { + MaterialTheme.colorScheme.inversePrimary + } else { + MaterialTheme.colorScheme.error + }, + navigationBarColor = MaterialTheme.colorScheme.background, + iconTintColor = + if (uiState.tunnelState.isSecured()) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onError + } + .copy(alpha = AlphaTopBar), + onSettingsClicked = onSettingsClick, + onAccountClicked = onAccountClick + ) { + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.Start, + modifier = + Modifier.fillMaxSize() + .verticalScroll(scrollState) + .drawVerticalScrollbar(scrollState) + .background(color = MaterialTheme.colorScheme.background) + .padding(it) + ) { + Image( + painter = painterResource(id = R.drawable.icon_fail), + contentDescription = null, + modifier = + Modifier.align(Alignment.CenterHorizontally) + .padding(vertical = Dimens.screenVerticalMargin) + ) + Text( + text = stringResource(id = R.string.out_of_time), + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = Dimens.sideMargin) + ) + Text( + text = + buildString { + append(stringResource(R.string.account_credit_has_expired)) + if (showSitePayment) { + append(" ") + append(stringResource(R.string.add_time_to_account)) + } + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimary, + modifier = + Modifier.padding( + top = Dimens.mediumPadding, + start = Dimens.sideMargin, + end = Dimens.sideMargin + ) + ) + Spacer(modifier = Modifier.weight(1f).defaultMinSize(minHeight = Dimens.verticalSpace)) + // Button area + if (uiState.tunnelState.showDisconnectButton()) { + ActionButton( + onClick = onDisconnectClick, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ), + text = stringResource(id = R.string.disconnect), + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.buttonSeparation + ) + ) + } + if (showSitePayment) { + SitePaymentButton( + onClick = onSitePaymentClick, + isEnabled = uiState.tunnelState.enableSitePaymentButton(), + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.buttonSeparation + ) + ) + } + RedeemVoucherButton( + onClick = onRedeemVoucherClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + isEnabled = uiState.tunnelState.enableRedeemButton() + ) + } + } +} + +private fun TunnelState.showDisconnectButton(): Boolean = + when (this) { + is TunnelState.Disconnected -> false + is TunnelState.Connecting, + is TunnelState.Connected -> true + is TunnelState.Disconnecting -> { + this.actionAfterDisconnect != ActionAfterDisconnect.Nothing + } + is TunnelState.Error -> this.errorState.isBlocking + } + +private fun TunnelState.enableSitePaymentButton(): Boolean = this is TunnelState.Disconnected + +private fun TunnelState.enableRedeemButton(): Boolean = + !(this is TunnelState.Error && this.errorState.cause is ErrorStateCause.IsOffline) 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 038eb60663db..b7dc83ac48a4 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 @@ -1,10 +1,8 @@ package net.mullvad.mullvadvpn.compose.screen -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -13,7 +11,6 @@ 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.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,17 +18,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.compose.button.ActionButton +import net.mullvad.mullvadvpn.compose.button.RedeemVoucherButton +import net.mullvad.mullvadvpn.compose.button.SitePaymentButton import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar import net.mullvad.mullvadvpn.compose.state.WelcomeUiState @@ -192,51 +186,25 @@ fun WelcomeScreen( ) { Spacer(modifier = Modifier.padding(top = Dimens.screenVerticalMargin)) if (showSitePayment) { - ActionButton( + SitePaymentButton( onClick = onSitePaymentClick, + isEnabled = true, modifier = Modifier.padding( start = Dimens.sideMargin, end = Dimens.sideMargin, bottom = Dimens.screenVerticalMargin - ), - colors = - ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.onPrimary, - containerColor = MaterialTheme.colorScheme.surface ) - ) { - Box(modifier = Modifier.fillMaxSize()) { - Text( - text = stringResource(id = R.string.buy_credit), - textAlign = TextAlign.Center, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.align(Alignment.Center) - ) - Image( - painter = painterResource(id = R.drawable.icon_extlink), - contentDescription = null, - modifier = - Modifier.align(Alignment.CenterEnd) - .padding(horizontal = Dimens.smallPadding) - ) - } - } + ) } - ActionButton( - text = stringResource(id = R.string.redeem_voucher), + RedeemVoucherButton( onClick = onRedeemVoucherClick, + isEnabled = true, modifier = Modifier.padding( start = Dimens.sideMargin, end = Dimens.sideMargin, bottom = Dimens.screenVerticalMargin - ), - colors = - ButtonDefaults.buttonColors( - contentColor = MaterialTheme.colorScheme.onPrimary, - containerColor = MaterialTheme.colorScheme.surface ) ) } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt new file mode 100644 index 000000000000..cc19ac7ca868 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt @@ -0,0 +1,5 @@ +package net.mullvad.mullvadvpn.compose.state + +import net.mullvad.mullvadvpn.model.TunnelState + +data class OutOfTimeUiState(val tunnelState: TunnelState = TunnelState.Disconnected) 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 3c4315ecb95b..987a55b45f7b 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 @@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceListViewModel import net.mullvad.mullvadvpn.viewmodel.DeviceRevokedViewModel import net.mullvad.mullvadvpn.viewmodel.LoginViewModel +import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel import net.mullvad.mullvadvpn.viewmodel.PrivacyDisclaimerViewModel import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import net.mullvad.mullvadvpn.viewmodel.SettingsViewModel @@ -86,6 +87,7 @@ val uiModule = module { viewModel { SettingsViewModel(get(), get()) } viewModel { VpnSettingsViewModel(get(), get(), get(), get()) } viewModel { WelcomeViewModel(get(), get(), get()) } + viewModel { OutOfTimeViewModel(get(), get()) } } const val SELF_PACKAGE_NAME = "SELF_PACKAGE_NAME" diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt index c5a5ee76346f..8d3bf00010e9 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/fragment/OutOfTimeFragment.kt @@ -4,197 +4,49 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.TextView -import androidx.core.view.isVisible -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import kotlin.properties.Delegates.observable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.emptyFlow -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.ComposeView import net.mullvad.mullvadvpn.R -import net.mullvad.mullvadvpn.constant.ACCOUNT_EXPIRY_POLL_INTERVAL +import net.mullvad.mullvadvpn.compose.screen.OutOfTimeScreen import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD -import net.mullvad.mullvadvpn.lib.common.util.JobTracker -import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser -import net.mullvad.mullvadvpn.model.TunnelState -import net.mullvad.mullvadvpn.repository.AccountRepository -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager -import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState -import net.mullvad.mullvadvpn.ui.serviceconnection.authTokenCache -import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy -import net.mullvad.mullvadvpn.ui.widget.Button -import net.mullvad.mullvadvpn.ui.widget.HeaderBar -import net.mullvad.mullvadvpn.ui.widget.RedeemVoucherButton -import net.mullvad.mullvadvpn.ui.widget.SitePaymentButton -import net.mullvad.mullvadvpn.util.callbackFlowFromNotifier -import net.mullvad.talpid.tunnel.ActionAfterDisconnect -import net.mullvad.talpid.tunnel.ErrorStateCause -import org.joda.time.DateTime -import org.koin.android.ext.android.inject +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.ui.MainActivity +import net.mullvad.mullvadvpn.viewmodel.OutOfTimeViewModel +import org.koin.androidx.viewmodel.ext.android.viewModel class OutOfTimeFragment : BaseFragment() { - // Injected dependencies - private val accountRepository: AccountRepository by inject() - private val serviceConnectionManager: ServiceConnectionManager by inject() - - private lateinit var headerBar: HeaderBar - - private lateinit var sitePaymentButton: SitePaymentButton - private lateinit var disconnectButton: Button - private lateinit var redeemButton: RedeemVoucherButton - - private var tunnelState by - observable(TunnelState.Disconnected) { _, _, state -> - updateDisconnectButton() - updateBuyButtons() - headerBar.tunnelState = state - } - - @Deprecated("Refactor code to instead rely on Lifecycle.") private val jobTracker = JobTracker() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - lifecycleScope.launchUiSubscriptionsOnResume() - } + private val vm by viewModel() override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { - val view = inflater.inflate(R.layout.out_of_time, container, false) - - headerBar = - view.findViewById(R.id.header_bar).apply { - tunnelState = this@OutOfTimeFragment.tunnelState - } - - view.findViewById(R.id.account_credit_has_expired).text = buildString { - append(requireActivity().getString(R.string.account_credit_has_expired)) - if (IS_PLAY_BUILD.not()) { - append(" ") - append(requireActivity().getString(R.string.add_time_to_account)) - } - } - - disconnectButton = - view.findViewById