From 26c734c0766beb5968e8a801df47232152da4d20 Mon Sep 17 00:00:00 2001 From: Jonatan Rhodin Date: Wed, 20 Sep 2023 14:05:15 +0200 Subject: [PATCH 1/6] Migrate out of time view to compose --- .../extensions/UriHandlerExtensions.kt | 12 + .../compose/screen/OutOfTimeScreen.kt | 280 ++++++++++++++++++ .../compose/state/OutOfTimeUiState.kt | 5 + .../net/mullvad/mullvadvpn/di/UiModule.kt | 2 + .../ui/fragment/OutOfTimeFragment.kt | 210 +++---------- .../ui/widget/RedeemVoucherButton.kt | 33 --- .../mullvadvpn/ui/widget/SitePaymentButton.kt | 27 -- .../mullvad/mullvadvpn/ui/widget/UrlButton.kt | 24 -- .../viewmodel/OutOfTimeViewModel.kt | 97 ++++++ .../app/src/main/res/layout/out_of_time.xml | 59 ---- .../src/main/res/layout/payment_buttons.xml | 15 - 11 files changed, 431 insertions(+), 333 deletions(-) create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/UriHandlerExtensions.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/OutOfTimeUiState.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/RedeemVoucherButton.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/SitePaymentButton.kt delete mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/ui/widget/UrlButton.kt create mode 100644 android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/OutOfTimeViewModel.kt delete mode 100644 android/app/src/main/res/layout/out_of_time.xml delete mode 100644 android/app/src/main/res/layout/payment_buttons.xml 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..b36882ef418f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/OutOfTimeScreen.kt @@ -0,0 +1,280 @@ +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.Box +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.draw.alpha +import androidx.compose.ui.platform.LocalUriHandler +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.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.AlphaDisabled +import net.mullvad.mullvadvpn.lib.theme.AlphaInactive +import net.mullvad.mullvadvpn.lib.theme.AlphaTopBar +import net.mullvad.mullvadvpn.lib.theme.AlphaVisible +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) { + ActionButton( + onClick = onSitePaymentClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.buttonSeparation + ), + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface, + disabledContentColor = + MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaInactive), + disabledContainerColor = + MaterialTheme.colorScheme.surface.copy(alpha = AlphaDisabled) + ), + isEnabled = uiState.tunnelState.enableSitePaymentButton() + ) { + Box(modifier = Modifier.fillMaxSize()) { + Text( + text = stringResource(id = R.string.buy_more_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) + .alpha( + if (uiState.tunnelState.enableSitePaymentButton()) + AlphaVisible + else AlphaDisabled + ) + ) + } + } + } + ActionButton( + text = stringResource(id = R.string.redeem_voucher), + onClick = onRedeemVoucherClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + colors = + ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colorScheme.onPrimary, + containerColor = MaterialTheme.colorScheme.surface, + disabledContentColor = + MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaInactive), + disabledContainerColor = + MaterialTheme.colorScheme.surface.copy(alpha = AlphaDisabled) + ), + 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/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