From 50804eed5ecc9040ccd73bacfe804229238fc9b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Tue, 5 Mar 2024 09:56:18 +0100 Subject: [PATCH] Fix long method lints --- .../compose/screen/ConnectScreen.kt | 286 ++++++++++-------- .../mullvadvpn/compose/screen/FilterScreen.kt | 130 +++++--- .../mullvadvpn/compose/screen/LoginScreen.kt | 177 +++++------ .../compose/screen/OutOfTimeScreen.kt | 128 ++++---- .../compose/screen/PrivacyDisclaimerScreen.kt | 169 +++++------ .../compose/screen/SettingsScreen.kt | 205 +++++++------ 6 files changed, 608 insertions(+), 487 deletions(-) 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 fec3ede17c56..bbc63c67784b 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 @@ -5,8 +5,11 @@ import android.net.Uri import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight @@ -19,7 +22,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableLongStateOf @@ -37,6 +39,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.popUpTo @@ -95,7 +98,7 @@ private fun PreviewConnectScreen() { val state = ConnectUiState.INITIAL AppTheme { ConnectScreen( - uiState = state, + state = state, ) } } @@ -105,7 +108,7 @@ private fun PreviewConnectScreen() { fun Connect(navigator: DestinationsNavigator) { val connectViewModel: ConnectViewModel = koinViewModel() - val state = connectViewModel.uiState.collectAsState().value + val state by connectViewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current LaunchedEffect(key1 = Unit) { @@ -130,7 +133,7 @@ fun Connect(navigator: DestinationsNavigator) { } } ConnectScreen( - uiState = state, + state = state, onDisconnectClick = connectViewModel::onDisconnectClick, onReconnectClick = connectViewModel::onReconnectClick, onConnectClick = connectViewModel::onConnectClick, @@ -159,8 +162,8 @@ fun Connect(navigator: DestinationsNavigator) { } @Composable -fun ConnectScreen( - uiState: ConnectUiState, +private fun ConnectScreen( + state: ConnectUiState, onDisconnectClick: () -> Unit = {}, onReconnectClick: () -> Unit = {}, onConnectClick: () -> Unit = {}, @@ -174,65 +177,22 @@ fun ConnectScreen( ) { val scrollState = rememberScrollState() - var lastConnectionActionTimestamp by remember { mutableLongStateOf(0L) } - - fun handleThrottledAction(action: () -> Unit) { - val currentTime = System.currentTimeMillis() - if ((currentTime - lastConnectionActionTimestamp) > CONNECT_BUTTON_THROTTLE_MILLIS) { - lastConnectionActionTimestamp = currentTime - action.invoke() - } - } ScaffoldWithTopBarAndDeviceName( - topBarColor = uiState.tunnelUiState.topBarColor(), - iconTintColor = uiState.tunnelUiState.iconTintColor(), + topBarColor = state.tunnelUiState.topBarColor(), + iconTintColor = state.tunnelUiState.iconTintColor(), onSettingsClicked = onSettingsClick, onAccountClicked = onAccountClick, - deviceName = uiState.deviceName, - timeLeft = uiState.daysLeftUntilExpiry + deviceName = state.deviceName, + timeLeft = state.daysLeftUntilExpiry ) { var progressIndicatorBias by remember { mutableFloatStateOf(0f) } - // Distance to marker when secure/unsecure - val baseZoom = - animateFloatAsState( - targetValue = - if (uiState.tunnelRealState is TunnelState.Connected) SECURE_ZOOM - else UNSECURE_ZOOM, - animationSpec = tween(SECURE_ZOOM_ANIMATION_MILLIS), - label = "baseZoom" - ) - - val markers = - uiState.tunnelRealState.toMarker(uiState.location)?.let { listOf(it) } ?: emptyList() - - AnimatedMap( - modifier = Modifier.padding(top = it.calculateTopPadding()), - cameraLocation = uiState.location?.toLatLong() ?: fallbackLatLong, - cameraBaseZoom = baseZoom.value, - cameraVerticalBias = progressIndicatorBias, - markers = markers, - globeColors = - GlobeColors( - landColor = MaterialTheme.colorScheme.primary, - oceanColor = MaterialTheme.colorScheme.secondary, - ) - ) - - Column( - verticalArrangement = Arrangement.Bottom, - horizontalAlignment = Alignment.Start, - modifier = - Modifier.animateContentSize() - .padding(top = it.calculateTopPadding()) - .fillMaxHeight() - .drawVerticalScrollbar( - scrollState, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar) - ) - .verticalScroll(scrollState) - .testTag(SCROLLABLE_COLUMN_TEST_TAG) + MapColumn( + state, + it, + progressIndicatorBias, + scrollState, ) { Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) MullvadCircularProgressIndicatorLarge( @@ -244,7 +204,7 @@ fun ConnectScreen( end = Dimens.sideMargin, top = Dimens.mediumPadding ) - .alpha(if (uiState.showLoading) AlphaVisible else AlphaInvisible) + .alpha(if (state.showLoading) AlphaVisible else AlphaInvisible) .align(Alignment.CenterHorizontally) .testTag(CIRCULAR_PROGRESS_INDICATOR) .onGloballyPositioned { @@ -259,72 +219,25 @@ fun ConnectScreen( } ) Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) - ConnectionStatusText( - state = uiState.tunnelRealState, - modifier = Modifier.padding(horizontal = Dimens.sideMargin) - ) - Text( - text = uiState.location?.country ?: "", - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.padding(horizontal = Dimens.sideMargin) - ) - Text( - text = uiState.location?.city ?: "", - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.padding(horizontal = Dimens.sideMargin) - ) - var expanded by rememberSaveable { mutableStateOf(false) } - LocationInfo( - onToggleTunnelInfo = { expanded = !expanded }, - isVisible = uiState.showLocationInfo, - isExpanded = expanded, - location = uiState.location, - inAddress = uiState.inAddress, - outAddress = uiState.outAddress, - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = Dimens.sideMargin) - .testTag(LOCATION_INFO_TEST_TAG) - ) - Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) - SwitchLocationButton( - modifier = - Modifier.fillMaxWidth() - .padding(horizontal = Dimens.sideMargin) - .testTag(SELECT_LOCATION_BUTTON_TEST_TAG), - onClick = onSwitchLocationClick, - showChevron = uiState.showLocation, - text = - if (uiState.showLocation && uiState.selectedRelayItem != null) { - uiState.selectedRelayItem.locationName - } else { - stringResource(id = R.string.switch_location) - } - ) + + ConnectionInfo(state = state) + Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) - ConnectionButton( - state = uiState.tunnelUiState, - modifier = - Modifier.padding(horizontal = Dimens.sideMargin) - .padding(bottom = Dimens.screenVerticalMargin) - .testTag(CONNECT_BUTTON_TEST_TAG), - disconnectClick = onDisconnectClick, - reconnectClick = { handleThrottledAction(onReconnectClick) }, - cancelClick = onCancelClick, - connectClick = { handleThrottledAction(onConnectClick) }, - reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG + + ButtonPanel( + state, + onSwitchLocationClick, + onDisconnectClick, + onReconnectClick, + onCancelClick, + onConnectClick, ) - // We need to manually add this padding so we align size with the map - // component and marker with the progress indicator. - Spacer(modifier = Modifier.height(it.calculateBottomPadding())) } NotificationBanner( modifier = Modifier.padding(top = it.calculateTopPadding()), - notification = uiState.inAppNotification, - isPlayBuild = uiState.isPlayBuild, + notification = state.inAppNotification, + isPlayBuild = state.isPlayBuild, onClickUpdateVersion = onUpdateVersionClick, onClickShowAccount = onManageAccountClick, onClickDismissNewDevice = onDismissNewDeviceClick, @@ -332,6 +245,141 @@ fun ConnectScreen( } } +@Composable +private fun MapColumn( + state: ConnectUiState, + it: PaddingValues, + progressIndicatorBias: Float, + scrollState: ScrollState, + content: @Composable ColumnScope.() -> Unit +) { + + // Distance to marker when secure/unsecure + val baseZoom = + animateFloatAsState( + targetValue = + if (state.tunnelRealState is TunnelState.Connected) SECURE_ZOOM else UNSECURE_ZOOM, + animationSpec = tween(SECURE_ZOOM_ANIMATION_MILLIS), + label = "baseZoom" + ) + + val markers = state.tunnelRealState.toMarker(state.location)?.let { listOf(it) } ?: emptyList() + + AnimatedMap( + modifier = Modifier.padding(top = it.calculateTopPadding()), + cameraLocation = state.location?.toLatLong() ?: fallbackLatLong, + cameraBaseZoom = baseZoom.value, + cameraVerticalBias = progressIndicatorBias, + markers = markers, + globeColors = + GlobeColors( + landColor = MaterialTheme.colorScheme.primary, + oceanColor = MaterialTheme.colorScheme.secondary, + ) + ) + + Column( + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = Alignment.Start, + modifier = + Modifier.animateContentSize() + .padding(top = it.calculateTopPadding()) + .fillMaxHeight() + .drawVerticalScrollbar( + scrollState, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar) + ) + .verticalScroll(scrollState) + .testTag(SCROLLABLE_COLUMN_TEST_TAG) + ) { + content() + // We need to manually add this padding so we align size with the map + // component and marker with the progress indicator. + Spacer(modifier = Modifier.height(it.calculateBottomPadding())) + } +} + +@Composable +private fun ConnectionInfo(state: ConnectUiState) { + ConnectionStatusText( + state = state.tunnelRealState, + modifier = Modifier.padding(horizontal = Dimens.sideMargin) + ) + Text( + text = state.location?.country ?: "", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = Dimens.sideMargin) + ) + Text( + text = state.location?.city ?: "", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.padding(horizontal = Dimens.sideMargin) + ) + var expanded by rememberSaveable { mutableStateOf(false) } + LocationInfo( + onToggleTunnelInfo = { expanded = !expanded }, + isVisible = state.showLocationInfo, + isExpanded = expanded, + location = state.location, + inAddress = state.inAddress, + outAddress = state.outAddress, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin) + .testTag(LOCATION_INFO_TEST_TAG) + ) +} + +@Composable +private fun ButtonPanel( + state: ConnectUiState, + onSwitchLocationClick: () -> Unit, + onDisconnectClick: () -> Unit, + onReconnectClick: () -> Unit, + onCancelClick: () -> Unit, + onConnectClick: () -> Unit, +) { + var lastConnectionActionTimestamp by remember { mutableLongStateOf(0L) } + + fun handleThrottledAction(action: () -> Unit) { + val currentTime = System.currentTimeMillis() + if ((currentTime - lastConnectionActionTimestamp) > CONNECT_BUTTON_THROTTLE_MILLIS) { + lastConnectionActionTimestamp = currentTime + action.invoke() + } + } + + SwitchLocationButton( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = Dimens.sideMargin) + .testTag(SELECT_LOCATION_BUTTON_TEST_TAG), + onClick = onSwitchLocationClick, + showChevron = state.showLocation, + text = + if (state.showLocation && state.selectedRelayItem != null) { + state.selectedRelayItem.locationName + } else { + stringResource(id = R.string.switch_location) + } + ) + Spacer(modifier = Modifier.height(Dimens.buttonSpacing)) + ConnectionButton( + state = state.tunnelUiState, + modifier = + Modifier.padding(horizontal = Dimens.sideMargin) + .padding(bottom = Dimens.screenVerticalMargin) + .testTag(CONNECT_BUTTON_TEST_TAG), + disconnectClick = onDisconnectClick, + reconnectClick = { handleThrottledAction(onReconnectClick) }, + cancelClick = onCancelClick, + connectClick = { handleThrottledAction(onConnectClick) }, + reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG + ) +} + @Composable fun TunnelState.toMarker(location: GeoIpLocation?): Marker? { if (location == null) return null diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt index 864f909421ce..b67807ad282e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/FilterScreen.kt @@ -18,7 +18,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -30,6 +29,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R @@ -58,7 +58,7 @@ private fun PreviewFilterScreen() { ) AppTheme { FilterScreen( - uiState = state, + state = state, onSelectedOwnership = {}, onSelectedProvider = { _, _ -> }, onAllProviderCheckChange = {}, @@ -70,7 +70,7 @@ private fun PreviewFilterScreen() { @Composable fun FilterScreen(navigator: DestinationsNavigator) { val viewModel = koinViewModel() - val uiState by viewModel.uiState.collectAsState() + val state by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { viewModel.uiSideEffect.collect { @@ -80,7 +80,7 @@ fun FilterScreen(navigator: DestinationsNavigator) { } } FilterScreen( - uiState = uiState, + state = state, onBackClick = navigator::navigateUp, onApplyClick = viewModel::onApplyButtonClicked, onSelectedOwnership = viewModel::setSelectedOwnership, @@ -91,7 +91,7 @@ fun FilterScreen(navigator: DestinationsNavigator) { @Composable fun FilterScreen( - uiState: RelayFilterState, + state: RelayFilterState, onBackClick: () -> Unit = {}, onApplyClick: () -> Unit = {}, onSelectedOwnership: (ownership: Ownership?) -> Unit = {}, @@ -134,7 +134,7 @@ fun FilterScreen( ) { ApplyButton( onClick = onApplyClick, - isEnabled = uiState.isApplyButtonEnabled, + isEnabled = state.isApplyButtonEnabled, modifier = Modifier.padding( start = Dimens.sideMargin, @@ -148,63 +148,105 @@ fun FilterScreen( LazyColumn(modifier = Modifier.padding(contentPadding).fillMaxSize()) { item { Divider() - ExpandableComposeCell( - title = stringResource(R.string.ownership), - isExpanded = ownershipExpanded, - isEnabled = true, - onInfoClicked = null, - onCellClicked = { ownershipExpanded = !ownershipExpanded } - ) + OwnershipHeader(ownershipExpanded, { ownershipExpanded = it }) } if (ownershipExpanded) { - item { - SelectableCell( - title = stringResource(id = R.string.any), - isSelected = uiState.selectedOwnership == null, - onCellClicked = { onSelectedOwnership(null) } - ) - } - items(uiState.filteredOwnershipByProviders) { ownership -> + item { AnyOwnership(state, onSelectedOwnership) } + items(state.filteredOwnershipByProviders) { ownership -> Divider() - SelectableCell( - title = stringResource(id = ownership.stringResource()), - isSelected = ownership == uiState.selectedOwnership, - onCellClicked = { onSelectedOwnership(ownership) } - ) + Ownership(ownership, state, onSelectedOwnership) } } item { Divider() - ExpandableComposeCell( - title = stringResource(R.string.providers), - isExpanded = providerExpanded, - isEnabled = true, - onInfoClicked = null, - onCellClicked = { providerExpanded = !providerExpanded } - ) + ProvidersHeader(providerExpanded, { providerExpanded = it }) } if (providerExpanded) { item { Divider() - CheckboxCell( - providerName = stringResource(R.string.all_providers), - checked = uiState.isAllProvidersChecked, - onCheckedChange = { isChecked -> onAllProviderCheckChange(isChecked) } - ) + AllProviders(state, onAllProviderCheckChange) } - items(uiState.filteredProvidersByOwnership) { provider -> + items(state.filteredProvidersByOwnership) { provider -> Divider() - CheckboxCell( - providerName = provider.name, - checked = provider in uiState.selectedProviders, - onCheckedChange = { checked -> onSelectedProvider(checked, provider) } - ) + Provider(provider, state, onSelectedProvider) } } } } } +@Composable +private fun OwnershipHeader(expanded: Boolean, onToggleExpanded: (Boolean) -> Unit) { + ExpandableComposeCell( + title = stringResource(R.string.ownership), + isExpanded = expanded, + isEnabled = true, + onInfoClicked = null, + onCellClicked = { onToggleExpanded(!expanded) } + ) +} + +@Composable +private fun AnyOwnership( + state: RelayFilterState, + onSelectedOwnership: (ownership: Ownership?) -> Unit +) { + SelectableCell( + title = stringResource(id = R.string.any), + isSelected = state.selectedOwnership == null, + onCellClicked = { onSelectedOwnership(null) } + ) +} + +@Composable +private fun Ownership( + ownership: Ownership, + state: RelayFilterState, + onSelectedOwnership: (ownership: Ownership?) -> Unit +) { + SelectableCell( + title = stringResource(id = ownership.stringResource()), + isSelected = ownership == state.selectedOwnership, + onCellClicked = { onSelectedOwnership(ownership) } + ) +} + +@Composable +private fun ProvidersHeader(expanded: Boolean, onToggleExpanded: (Boolean) -> Unit) { + ExpandableComposeCell( + title = stringResource(R.string.providers), + isExpanded = expanded, + isEnabled = true, + onInfoClicked = null, + onCellClicked = { onToggleExpanded(!expanded) } + ) +} + +@Composable +private fun AllProviders( + state: RelayFilterState, + onAllProviderCheckChange: (isChecked: Boolean) -> Unit +) { + CheckboxCell( + providerName = stringResource(R.string.all_providers), + checked = state.isAllProvidersChecked, + onCheckedChange = { isChecked -> onAllProviderCheckChange(isChecked) } + ) +} + +@Composable +private fun Provider( + provider: Provider, + state: RelayFilterState, + onSelectedProvider: (checked: Boolean, provider: Provider) -> Unit +) { + CheckboxCell( + providerName = provider.name, + checked = provider in state.selectedProviders, + onCheckedChange = { checked -> onSelectedProvider(checked, provider) } + ) +} + private fun Ownership.stringResource(): Int = when (this) { Ownership.MullvadOwned -> R.string.mullvad_owned_only 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 4dac203fa871..d0f7d91a999f 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 @@ -27,7 +27,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -50,6 +49,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.popUpTo @@ -85,33 +85,31 @@ import org.koin.androidx.compose.koinViewModel @Preview @Composable private fun PreviewIdle() { - AppTheme { LoginScreen(uiState = LoginUiState()) } + AppTheme { LoginScreen(state = LoginUiState()) } } @Preview @Composable private fun PreviewLoggingIn() { - AppTheme { LoginScreen(uiState = LoginUiState(loginState = Loading.LoggingIn)) } + AppTheme { LoginScreen(state = LoginUiState(loginState = Loading.LoggingIn)) } } @Preview @Composable private fun PreviewCreatingAccount() { - AppTheme { LoginScreen(uiState = LoginUiState(loginState = Loading.CreatingAccount)) } + AppTheme { LoginScreen(state = LoginUiState(loginState = Loading.CreatingAccount)) } } @Preview @Composable private fun PreviewLoginError() { - AppTheme { - LoginScreen(uiState = LoginUiState(loginState = Idle(LoginError.InvalidCredentials))) - } + AppTheme { LoginScreen(state = LoginUiState(loginState = Idle(LoginError.InvalidCredentials))) } } @Preview @Composable private fun PreviewLoginSuccess() { - AppTheme { LoginScreen(uiState = LoginUiState(loginState = Success)) } + AppTheme { LoginScreen(state = LoginUiState(loginState = Success)) } } @Destination(style = LoginTransition::class) @@ -121,7 +119,7 @@ fun Login( accountToken: String? = null, vm: LoginViewModel = koinViewModel() ) { - val state by vm.uiState.collectAsState() + val state by vm.uiState.collectAsStateWithLifecycle() // Login with argument, e.g when user comes from Too Many Devices screen LaunchedEffect(accountToken) { @@ -171,7 +169,7 @@ fun Login( @Composable private fun LoginScreen( - uiState: LoginUiState, + state: LoginUiState, onLoginClick: (String) -> Unit = {}, onCreateAccountClick: () -> Unit = {}, onDeleteHistoryClick: () -> Unit = {}, @@ -182,7 +180,7 @@ private fun LoginScreen( topBarColor = MaterialTheme.colorScheme.primary, iconTintColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClick, - enabled = uiState.loginState is Idle, + enabled = state.loginState is Idle, onAccountClicked = null, ) { val scrollState = rememberScrollState() @@ -195,29 +193,28 @@ private fun LoginScreen( ) { Spacer(modifier = Modifier.weight(1f)) LoginIcon( - uiState.loginState, + state.loginState, modifier = Modifier.align(Alignment.CenterHorizontally) .padding(bottom = Dimens.largePadding) ) - LoginContent(uiState, onAccountNumberChange, onLoginClick, onDeleteHistoryClick) + LoginContent(state, onAccountNumberChange, onLoginClick, onDeleteHistoryClick) Spacer(modifier = Modifier.weight(3f)) - CreateAccountPanel(onCreateAccountClick, isEnabled = uiState.loginState is Idle) + CreateAccountPanel(onCreateAccountClick, isEnabled = state.loginState is Idle) } } } @Composable -@OptIn(ExperimentalComposeUiApi::class) private fun LoginContent( - uiState: LoginUiState, + state: LoginUiState, onAccountNumberChange: (String) -> Unit, onLoginClick: (String) -> Unit, onDeleteHistoryClick: () -> Unit ) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = Dimens.sideMargin)) { Text( - text = uiState.loginState.title(), + text = state.loginState.title(), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onPrimary, modifier = @@ -226,87 +223,24 @@ private fun LoginContent( .padding(bottom = Dimens.smallPadding) ) - var tfFocusState: FocusState? by remember { mutableStateOf(null) } - var ddFocusState: FocusState? by remember { mutableStateOf(null) } - val expandedDropdown = tfFocusState?.hasFocus ?: false || ddFocusState?.hasFocus ?: false - Text( modifier = Modifier.padding(bottom = Dimens.smallPadding), - text = uiState.loginState.supportingText() ?: "", + text = state.loginState.supportingText() ?: "", style = MaterialTheme.typography.labelMedium, color = - if (uiState.loginState.isError()) { + if (state.loginState.isError()) { MaterialTheme.colorScheme.error } else { MaterialTheme.colorScheme.onPrimary }, ) - TextField( - modifier = - // Fix for DPad navigation - Modifier.onFocusChanged { tfFocusState = it } - .focusProperties { - left = FocusRequester.Cancel - right = FocusRequester.Cancel - } - .fillMaxWidth() - .testTag(LOGIN_INPUT_TEST_TAG) - .let { - if (!expandedDropdown || uiState.lastUsedAccount == null) { - it.clip(MaterialTheme.shapes.small) - } else { - it - } - }, - value = uiState.accountNumberInput, - label = { - Text( - text = stringResource(id = R.string.login_description), - color = Color.Unspecified, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - keyboardActions = - KeyboardActions(onDone = { onLoginClick(uiState.accountNumberInput) }), - keyboardOptions = - KeyboardOptions( - imeAction = if (uiState.loginButtonEnabled) ImeAction.Done else ImeAction.None, - keyboardType = KeyboardType.NumberPassword - ), - onValueChange = onAccountNumberChange, - singleLine = true, - maxLines = 1, - visualTransformation = accountTokenVisualTransformation(), - enabled = uiState.loginState is Idle, - colors = mullvadWhiteTextFieldColors(), - isError = uiState.loginState.isError(), - ) - - AnimatedVisibility(visible = uiState.lastUsedAccount != null && expandedDropdown) { - val token = uiState.lastUsedAccount?.value.orEmpty() - val accountTransformation = remember { accountTokenVisualTransformation() } - val transformedText = - remember(token) { accountTransformation.filter(AnnotatedString(token)).text } - - AccountDropDownItem( - modifier = Modifier.onFocusChanged { ddFocusState = it }, - accountToken = transformedText.toString(), - onClick = { - uiState.lastUsedAccount?.let { - onAccountNumberChange(it.value) - onLoginClick(it.value) - } - }, - onDeleteClick = onDeleteHistoryClick - ) - } + LoginInput(state = state, onLoginClick, onAccountNumberChange, onDeleteHistoryClick) Spacer(modifier = Modifier.size(Dimens.largePadding)) VariantButton( - isEnabled = uiState.loginButtonEnabled, - onClick = { onLoginClick(uiState.accountNumberInput) }, + isEnabled = state.loginButtonEnabled, + onClick = { onLoginClick(state.accountNumberInput) }, text = stringResource(id = R.string.login_title), modifier = Modifier.padding(bottom = Dimens.mediumPadding) ) @@ -337,6 +271,79 @@ private fun LoginIcon(loginState: LoginState, modifier: Modifier = Modifier) { } } +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun LoginInput( + state: LoginUiState, + onLoginClick: (String) -> Unit, + onAccountNumberChange: (String) -> Unit, + onDeleteHistoryClick: () -> Unit +) { + var tfFocusState: FocusState? by remember { mutableStateOf(null) } + var ddFocusState: FocusState? by remember { mutableStateOf(null) } + val expandedDropdown = tfFocusState?.hasFocus ?: false || ddFocusState?.hasFocus ?: false + + TextField( + modifier = + // Fix for DPad navigation + Modifier.onFocusChanged { tfFocusState = it } + .focusProperties { + left = FocusRequester.Cancel + right = FocusRequester.Cancel + } + .fillMaxWidth() + .testTag(LOGIN_INPUT_TEST_TAG) + .let { + if (!expandedDropdown || state.lastUsedAccount == null) { + it.clip(MaterialTheme.shapes.small) + } else { + it + } + }, + value = state.accountNumberInput, + label = { + Text( + text = stringResource(id = R.string.login_description), + color = Color.Unspecified, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + keyboardActions = KeyboardActions(onDone = { onLoginClick(state.accountNumberInput) }), + keyboardOptions = + KeyboardOptions( + imeAction = if (state.loginButtonEnabled) ImeAction.Done else ImeAction.None, + keyboardType = KeyboardType.NumberPassword + ), + onValueChange = onAccountNumberChange, + singleLine = true, + maxLines = 1, + visualTransformation = accountTokenVisualTransformation(), + enabled = state.loginState is Idle, + colors = mullvadWhiteTextFieldColors(), + isError = state.loginState.isError(), + ) + + AnimatedVisibility(visible = state.lastUsedAccount != null && expandedDropdown) { + val token = state.lastUsedAccount?.value.orEmpty() + val accountTransformation = remember { accountTokenVisualTransformation() } + val transformedText = + remember(token) { accountTransformation.filter(AnnotatedString(token)).text } + + AccountDropDownItem( + modifier = Modifier.onFocusChanged { ddFocusState = it }, + accountToken = transformedText.toString(), + onClick = { + state.lastUsedAccount?.let { + onAccountNumberChange(it.value) + onLoginClick(it.value) + } + }, + onDeleteClick = onDeleteHistoryClick + ) + } +} + @Composable private fun LoginState.title(): String = stringResource( 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 index 00cd339c096f..7aa1f9024261 100644 --- 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 @@ -14,7 +14,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler @@ -22,6 +22,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.popUpTo @@ -62,7 +63,7 @@ import org.koin.androidx.compose.koinViewModel private fun PreviewOutOfTimeScreenDisconnected() { AppTheme { OutOfTimeScreen( - uiState = + state = OutOfTimeUiState( tunnelState = TunnelState.Disconnected(), "Heroic Frog", @@ -77,7 +78,7 @@ private fun PreviewOutOfTimeScreenDisconnected() { private fun PreviewOutOfTimeScreenConnecting() { AppTheme { OutOfTimeScreen( - uiState = + state = OutOfTimeUiState( tunnelState = TunnelState.Connecting(null, null), "Strong Rabbit", @@ -92,7 +93,7 @@ private fun PreviewOutOfTimeScreenConnecting() { private fun PreviewOutOfTimeScreenError() { AppTheme { OutOfTimeScreen( - uiState = + state = OutOfTimeUiState( tunnelState = TunnelState.Error( @@ -113,7 +114,7 @@ fun OutOfTime( playPaymentResultRecipient: ResultRecipient ) { val vm = koinViewModel() - val state = vm.uiState.collectAsState().value + val state by vm.uiState.collectAsStateWithLifecycle() redeemVoucherResultRecipient.onNavResult { // If we successfully redeemed a voucher, navigate to Connect screen if (it is NavResult.Value && it.value) { @@ -150,7 +151,7 @@ fun OutOfTime( } OutOfTimeScreen( - uiState = state, + state = state, onSitePaymentClick = vm::onSitePaymentClick, onRedeemVoucherClick = { navigator.navigate(RedeemVoucherDestination) { launchSingleTop = true } @@ -169,7 +170,7 @@ fun OutOfTime( @Composable fun OutOfTimeScreen( - uiState: OutOfTimeUiState, + state: OutOfTimeUiState, onDisconnectClick: () -> Unit = {}, onSitePaymentClick: () -> Unit = {}, onRedeemVoucherClick: () -> Unit = {}, @@ -182,13 +183,13 @@ fun OutOfTimeScreen( val scrollState = rememberScrollState() ScaffoldWithTopBarAndDeviceName( topBarColor = - if (uiState.tunnelState.isSecured()) { + if (state.tunnelState.isSecured()) { MaterialTheme.colorScheme.inversePrimary } else { MaterialTheme.colorScheme.error }, iconTintColor = - if (uiState.tunnelState.isSecured()) { + if (state.tunnelState.isSecured()) { MaterialTheme.colorScheme.onPrimary } else { MaterialTheme.colorScheme.onError @@ -196,7 +197,7 @@ fun OutOfTimeScreen( .copy(alpha = AlphaTopBar), onSettingsClicked = onSettingsClick, onAccountClicked = onAccountClick, - deviceName = uiState.deviceName, + deviceName = state.deviceName, timeLeft = null ) { Column( @@ -230,7 +231,7 @@ fun OutOfTimeScreen( text = buildString { append(stringResource(R.string.account_credit_has_expired)) - if (uiState.showSitePayment) { + if (state.showSitePayment) { append(" ") append(stringResource(R.string.add_time_to_account)) } @@ -246,57 +247,80 @@ fun OutOfTimeScreen( ) Spacer(modifier = Modifier.weight(1f).defaultMinSize(minHeight = Dimens.verticalSpace)) // Button area - if (uiState.tunnelState.showDisconnectButton()) { - NegativeButton( - onClick = onDisconnectClick, - text = stringResource(id = R.string.disconnect), - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing - ) - ) - } - uiState.billingPaymentState?.let { - PlayPayment( - billingPaymentState = uiState.billingPaymentState, - onPurchaseBillingProductClick = { productId -> - onPurchaseBillingProductClick(productId) - }, - onInfoClick = navigateToVerificationPendingDialog, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - bottom = Dimens.buttonSpacing - ) - .align(Alignment.CenterHorizontally) - ) - } - if (uiState.showSitePayment) { - SitePaymentButton( - onClick = onSitePaymentClick, - isEnabled = uiState.tunnelState.enableSitePaymentButton(), - modifier = - Modifier.padding( + + ButtonPanel( + state = state, + onDisconnectClick = onDisconnectClick, + onPurchaseBillingProductClick = onPurchaseBillingProductClick, + onRedeemVoucherClick = onRedeemVoucherClick, + onSitePaymentClick = onSitePaymentClick, + navigateToVerificationPendingDialog = navigateToVerificationPendingDialog + ) + } + } +} + +@Composable +private fun ButtonPanel( + state: OutOfTimeUiState, + onDisconnectClick: () -> Unit, + onPurchaseBillingProductClick: (ProductId) -> Unit, + onRedeemVoucherClick: () -> Unit, + onSitePaymentClick: () -> Unit, + navigateToVerificationPendingDialog: () -> Unit +) { + + Column { + if (state.tunnelState.showDisconnectButton()) { + NegativeButton( + onClick = onDisconnectClick, + text = stringResource(id = R.string.disconnect), + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.buttonSpacing + ) + ) + } + state.billingPaymentState?.let { + PlayPayment( + billingPaymentState = state.billingPaymentState, + onPurchaseBillingProductClick = { productId -> + onPurchaseBillingProductClick(productId) + }, + onInfoClick = navigateToVerificationPendingDialog, + modifier = + Modifier.padding( start = Dimens.sideMargin, end = Dimens.sideMargin, bottom = Dimens.buttonSpacing ) - ) - } - RedeemVoucherButton( - onClick = onRedeemVoucherClick, + .align(Alignment.CenterHorizontally) + ) + } + if (state.showSitePayment) { + SitePaymentButton( + onClick = onSitePaymentClick, + isEnabled = state.tunnelState.enableSitePaymentButton(), modifier = Modifier.padding( start = Dimens.sideMargin, end = Dimens.sideMargin, - bottom = Dimens.screenVerticalMargin - ), - isEnabled = uiState.tunnelState.enableRedeemButton() + bottom = Dimens.buttonSpacing + ) ) } + RedeemVoucherButton( + onClick = onRedeemVoucherClick, + modifier = + Modifier.padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + bottom = Dimens.screenVerticalMargin + ), + isEnabled = state.tunnelState.enableRedeemButton() + ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt index 3f52af5fc3bc..a7a7f3bce690 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/PrivacyDisclaimerScreen.kt @@ -2,10 +2,12 @@ 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.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -16,7 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -31,8 +33,7 @@ import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.navigation.popUpTo @@ -70,7 +71,7 @@ fun PrivacyDisclaimer( navigator: DestinationsNavigator, ) { val viewModel: PrivacyDisclaimerViewModel = koinViewModel() - val uiState = viewModel.uiState.collectAsState() + val state by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val scope = rememberCoroutineScope() @@ -105,112 +106,98 @@ fun PrivacyDisclaimer( } } } - PrivacyDisclaimerScreen(uiState.value, {}, viewModel::setPrivacyDisclosureAccepted) + PrivacyDisclaimerScreen(state, {}, viewModel::setPrivacyDisclosureAccepted) } @Composable fun PrivacyDisclaimerScreen( - uiState: PrivacyDisclaimerViewState, + state: PrivacyDisclaimerViewState, onPrivacyPolicyLinkClicked: () -> Unit, onAcceptClicked: () -> Unit, ) { val topColor = MaterialTheme.colorScheme.primary ScaffoldWithTopBar(topBarColor = topColor, onAccountClicked = null, onSettingsClicked = null) { - ConstraintLayout( - modifier = - Modifier.padding(it) - .fillMaxSize() - .background(color = MaterialTheme.colorScheme.background) + val scrollState = rememberScrollState() + Column( + Modifier.padding(it) + .padding(horizontal = Dimens.sideMargin, vertical = Dimens.screenVerticalMargin) + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + .drawVerticalScrollbar( + state = scrollState, + color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar) + ), + verticalArrangement = Arrangement.SpaceBetween ) { - val (body, actionButtons) = createRefs() - val sideMargin = Dimens.sideMargin - val scrollState = rememberScrollState() + Content(onPrivacyPolicyLinkClicked) - Column( - modifier = - Modifier.constrainAs(body) { - top.linkTo(parent.top) - start.linkTo(parent.start) - end.linkTo(parent.end) - bottom.linkTo(actionButtons.top) - width = Dimension.fillToConstraints - height = Dimension.fillToConstraints - } - .drawVerticalScrollbar( - state = scrollState, - color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar) - ) - .verticalScroll(scrollState) - .padding(sideMargin), - ) { - Text( - text = stringResource(id = R.string.privacy_disclaimer_title), - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.Bold - ) + ButtonPanel(state.isStartingService, onAcceptClicked) + } + } +} - val fontSize = 14.sp - Text( - text = stringResource(id = R.string.privacy_disclaimer_body_first_paragraph), - fontSize = fontSize, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(top = 10.dp) - ) +@Composable +private fun Content(onPrivacyPolicyLinkClicked: () -> Unit) { + Column { + Text( + text = stringResource(id = R.string.privacy_disclaimer_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Bold + ) - Spacer(modifier = Modifier.height(fontSize.toDp() + Dimens.smallPadding)) + val fontSize = 14.sp + Text( + text = stringResource(id = R.string.privacy_disclaimer_body_first_paragraph), + fontSize = fontSize, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(top = 10.dp) + ) - Text( - text = stringResource(id = R.string.privacy_disclaimer_body_second_paragraph), - fontSize = fontSize, - color = MaterialTheme.colorScheme.onBackground, - ) + Spacer(modifier = Modifier.height(fontSize.toDp() + Dimens.smallPadding)) - Row(modifier = Modifier.padding(top = 10.dp)) { - ClickableText( - text = AnnotatedString(stringResource(id = R.string.privacy_policy_label)), - onClick = { onPrivacyPolicyLinkClicked() }, - style = - TextStyle( - fontSize = 12.sp, - color = Color.White, - textDecoration = TextDecoration.Underline - ) - ) + Text( + text = stringResource(id = R.string.privacy_disclaimer_body_second_paragraph), + fontSize = fontSize, + color = MaterialTheme.colorScheme.onBackground, + ) - Image( - painter = painterResource(id = R.drawable.icon_extlink), - contentDescription = null, - modifier = - Modifier.align(Alignment.CenterVertically) - .padding(start = 2.dp, top = 2.dp) - .width(10.dp) - .height(10.dp) + Row(modifier = Modifier.padding(top = 10.dp)) { + ClickableText( + text = AnnotatedString(stringResource(id = R.string.privacy_policy_label)), + onClick = { onPrivacyPolicyLinkClicked() }, + style = + TextStyle( + fontSize = 12.sp, + color = Color.White, + textDecoration = TextDecoration.Underline ) - } - } + ) - Column( + Image( + painter = painterResource(id = R.drawable.icon_extlink), + contentDescription = null, modifier = - Modifier.constrainAs(actionButtons) { - top.linkTo(body.bottom, margin = sideMargin) - start.linkTo(parent.start, margin = sideMargin) - end.linkTo(parent.end, margin = sideMargin) - bottom.linkTo(parent.bottom, margin = sideMargin) - width = Dimension.fillToConstraints - height = Dimension.preferredWrapContent - }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (uiState.isStartingService) { - MullvadCircularProgressIndicatorMedium() - } else { - PrimaryButton( - text = stringResource(id = R.string.agree_and_continue), - onClick = onAcceptClicked::invoke - ) - } - } + Modifier.align(Alignment.CenterVertically) + .padding(start = 2.dp, top = 2.dp) + .width(10.dp) + .height(10.dp) + ) + } + } +} + +@Composable +private fun ButtonPanel(isStartingService: Boolean, onAcceptClicked: () -> Unit) { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + if (isStartingService) { + MullvadCircularProgressIndicatorMedium() + } else { + PrimaryButton( + text = stringResource(id = R.string.agree_and_continue), + onClick = onAcceptClicked::invoke + ) } } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt index 9805a7bb3bb5..f3585050a4a8 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SettingsScreen.kt @@ -1,5 +1,6 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Context import android.net.Uri import androidx.compose.animation.animateContentSize import androidx.compose.foundation.background @@ -11,13 +12,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator import net.mullvad.mullvadvpn.R @@ -46,7 +47,7 @@ import org.koin.androidx.compose.koinViewModel private fun PreviewSettings() { AppTheme { SettingsScreen( - uiState = + state = SettingsUiState( appVersion = "2222.22", isLoggedIn = true, @@ -62,9 +63,9 @@ private fun PreviewSettings() { @Composable fun Settings(navigator: DestinationsNavigator) { val vm = koinViewModel() - val state by vm.uiState.collectAsState() + val state by vm.uiState.collectAsStateWithLifecycle() SettingsScreen( - uiState = state, + state = state, onVpnSettingCellClick = { navigator.navigate(VpnSettingsDestination) { launchSingleTop = true } }, @@ -81,7 +82,7 @@ fun Settings(navigator: DestinationsNavigator) { @ExperimentalMaterial3Api @Composable fun SettingsScreen( - uiState: SettingsUiState, + state: SettingsUiState, onVpnSettingCellClick: () -> Unit = {}, onSplitTunnelingCellClick: () -> Unit = {}, onReportProblemCellClick: () -> Unit = {}, @@ -98,113 +99,125 @@ fun SettingsScreen( state = lazyListState ) { item { Spacer(modifier = Modifier.height(Dimens.cellLabelVerticalPadding)) } - if (uiState.isLoggedIn) { + if (state.isLoggedIn) { item { NavigationComposeCell( title = stringResource(id = R.string.settings_vpn), onClick = { onVpnSettingCellClick() } ) } + item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } + item { SplitTunneling(onSplitTunnelingCellClick) } + item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } + } - item { - Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) - NavigationComposeCell( - title = stringResource(id = R.string.split_tunneling), - onClick = { onSplitTunnelingCellClick() } - ) - Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) - } + item { AppVersion(context, state) } + + item { Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) } + + itemWithDivider { ReportProblem(onReportProblemCellClick) } + + if (!state.isPlayBuild) { + itemWithDivider { FaqAndGuides(context) } } - item { - NavigationComposeCell( - title = stringResource(id = R.string.app_version), - onClick = { - context.openLink( - Uri.parse( - context.resources - .getString(R.string.download_url) - .appendHideNavOnPlayBuild(uiState.isPlayBuild) - ) - ) - }, - bodyView = - @Composable { - if (!uiState.isPlayBuild) { - NavigationCellBody( - content = uiState.appVersion, - contentBodyDescription = - stringResource(id = R.string.app_version), - isExternalLink = true, - ) - } else { - Text( - text = uiState.appVersion, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondary - ) - } - }, - showWarning = uiState.isUpdateAvailable, - isRowEnabled = !uiState.isPlayBuild + + itemWithDivider { PrivacyPolicy(context, state) } + } + } +} + +@Composable +private fun SplitTunneling(onSplitTunnelingCellClick: () -> Unit) { + NavigationComposeCell( + title = stringResource(id = R.string.split_tunneling), + onClick = onSplitTunnelingCellClick + ) +} + +@Composable +private fun AppVersion(context: Context, state: SettingsUiState) { + NavigationComposeCell( + title = stringResource(id = R.string.app_version), + onClick = { + context.openLink( + Uri.parse( + context.resources + .getString(R.string.download_url) + .appendHideNavOnPlayBuild(state.isPlayBuild) ) - } - if (uiState.isUpdateAvailable) { - item { + ) + }, + bodyView = + @Composable { + if (!state.isPlayBuild) { + NavigationCellBody( + content = state.appVersion, + contentBodyDescription = stringResource(id = R.string.app_version), + isExternalLink = true, + ) + } else { Text( - text = stringResource(id = R.string.update_available_footer), + text = state.appVersion, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSecondary, - modifier = - Modifier.background(MaterialTheme.colorScheme.secondary) - .padding( - start = Dimens.cellStartPadding, - top = Dimens.cellTopPadding, - end = Dimens.cellStartPadding, - bottom = Dimens.cellLabelVerticalPadding, - ) + color = MaterialTheme.colorScheme.onSecondary ) } - } - - itemWithDivider { - Spacer(modifier = Modifier.height(Dimens.cellVerticalSpacing)) - NavigationComposeCell( - title = stringResource(id = R.string.report_a_problem), - onClick = { onReportProblemCellClick() } - ) - } + }, + showWarning = state.isUpdateAvailable, + isRowEnabled = !state.isPlayBuild + ) - if (!uiState.isPlayBuild) { - itemWithDivider { - val faqGuideLabel = stringResource(id = R.string.faqs_and_guides) - NavigationComposeCell( - title = faqGuideLabel, - bodyView = @Composable { DefaultExternalLinkView(faqGuideLabel) }, - onClick = { - context.openLink( - Uri.parse(context.resources.getString(R.string.faqs_and_guides_url)) - ) - } + if (state.isUpdateAvailable) { + Text( + text = stringResource(id = R.string.update_available_footer), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary, + modifier = + Modifier.background(MaterialTheme.colorScheme.secondary) + .padding( + start = Dimens.cellStartPadding, + top = Dimens.cellTopPadding, + end = Dimens.cellStartPadding, + bottom = Dimens.cellLabelVerticalPadding, ) - } - } + ) + } +} - itemWithDivider { - val privacyPolicyLabel = stringResource(id = R.string.privacy_policy_label) - NavigationComposeCell( - title = privacyPolicyLabel, - bodyView = @Composable { DefaultExternalLinkView(privacyPolicyLabel) }, - onClick = { - context.openLink( - Uri.parse( - context.resources - .getString(R.string.privacy_policy_url) - .appendHideNavOnPlayBuild(uiState.isPlayBuild) - ) - ) - } +@Composable +private fun ReportProblem(onReportProblemCellClick: () -> Unit) { + NavigationComposeCell( + title = stringResource(id = R.string.report_a_problem), + onClick = { onReportProblemCellClick() } + ) +} + +@Composable +private fun FaqAndGuides(context: Context) { + val faqGuideLabel = stringResource(id = R.string.faqs_and_guides) + NavigationComposeCell( + title = faqGuideLabel, + bodyView = @Composable { DefaultExternalLinkView(faqGuideLabel) }, + onClick = { + context.openLink(Uri.parse(context.resources.getString(R.string.faqs_and_guides_url))) + } + ) +} + +@Composable +private fun PrivacyPolicy(context: Context, state: SettingsUiState) { + val privacyPolicyLabel = stringResource(id = R.string.privacy_policy_label) + NavigationComposeCell( + title = privacyPolicyLabel, + bodyView = @Composable { DefaultExternalLinkView(privacyPolicyLabel) }, + onClick = { + context.openLink( + Uri.parse( + context.resources + .getString(R.string.privacy_policy_url) + .appendHideNavOnPlayBuild(state.isPlayBuild) ) - } + ) } - } + ) }