From 14686d4d19e1630770c4dc9fb35fb782193f8633 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20G=C3=B6ransson?= Date: Tue, 13 Feb 2024 16:28:56 +0100 Subject: [PATCH] Integrate map into ConnectScreen --- .../notificationbanner/NotificationBanner.kt | 3 +- .../compose/screen/ConnectScreen.kt | 170 +++++++++++++----- .../compose/state/ConnectUiState.kt | 12 +- .../mullvadvpn/constant/AnimationConstant.kt | 10 ++ .../mullvadvpn/viewmodel/ConnectViewModel.kt | 3 +- 5 files changed, 147 insertions(+), 51 deletions(-) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt index 3d4a71f1afff..94dc40a17525 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/notificationbanner/NotificationBanner.kt @@ -89,6 +89,7 @@ private fun PreviewNotificationBanner() { @Composable fun NotificationBanner( + modifier: Modifier, notification: InAppNotification?, isPlayBuild: Boolean, onClickUpdateVersion: () -> Unit, @@ -101,7 +102,7 @@ fun NotificationBanner( visible = notification != null, enter = slideInVertically(initialOffsetY = { -it }), exit = slideOutVertically(targetOffsetY = { -it }), - modifier = Modifier.animateContentSize() + modifier = modifier ) { val visibleNotification = notification ?: previous if (visibleNotification != null) 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 f626191b4cbb..ffad21bf7e2d 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 @@ -2,10 +2,13 @@ package net.mullvad.mullvadvpn.compose.screen import android.content.Intent import android.net.Uri -import androidx.compose.foundation.background +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -18,6 +21,7 @@ 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,6 +29,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -55,15 +63,26 @@ import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SCROLLABLE_COLUMN_TEST_TAG import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG import net.mullvad.mullvadvpn.compose.transitions.HomeTransition +import net.mullvad.mullvadvpn.constant.SECURE_ZOOM +import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS +import net.mullvad.mullvadvpn.constant.UNSECURE_ZOOM +import net.mullvad.mullvadvpn.constant.fallbackLatLong import net.mullvad.mullvadvpn.lib.common.util.openAccountPageInBrowser +import net.mullvad.mullvadvpn.lib.map.AnimatedMap +import net.mullvad.mullvadvpn.lib.map.data.GlobeColors +import net.mullvad.mullvadvpn.lib.map.data.LocationMarkerColors +import net.mullvad.mullvadvpn.lib.map.data.Marker import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar import net.mullvad.mullvadvpn.lib.theme.color.AlphaTopBar +import net.mullvad.mullvadvpn.model.GeoIpLocation +import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.model.Latitude +import net.mullvad.mullvadvpn.model.Longitude import net.mullvad.mullvadvpn.model.TunnelState import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel -import net.mullvad.talpid.tunnel.ActionAfterDisconnect import org.koin.androidx.compose.koinViewModel private const val CONNECT_BUTTON_THROTTLE_MILLIS = 1000 @@ -164,66 +183,85 @@ fun ConnectScreen( } ScaffoldWithTopBarAndDeviceName( - topBarColor = - if (uiState.tunnelUiState.isSecured()) { - MaterialTheme.colorScheme.inversePrimary - } else { - MaterialTheme.colorScheme.error - }, - iconTintColor = - if (uiState.tunnelUiState.isSecured()) { - MaterialTheme.colorScheme.onPrimary - } else { - MaterialTheme.colorScheme.onError - } - .copy(alpha = AlphaTopBar), + topBarColor = uiState.tunnelUiState.topBarColor(), + iconTintColor = uiState.tunnelUiState.iconTintColor(), onSettingsClicked = onSettingsClick, onAccountClicked = onAccountClick, deviceName = uiState.deviceName, timeLeft = uiState.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, + ) + ) + + NotificationBanner( + modifier = Modifier.padding(top = it.calculateTopPadding()), + notification = uiState.inAppNotification, + isPlayBuild = uiState.isPlayBuild, + onClickUpdateVersion = onUpdateVersionClick, + onClickShowAccount = onManageAccountClick, + onClickDismissNewDevice = onDismissNewDeviceClick, + ) + Column( verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.Start, modifier = - Modifier.background(color = MaterialTheme.colorScheme.primary) - .padding(it) + Modifier.animateContentSize() + .padding(top = it.calculateTopPadding()) .fillMaxHeight() .drawVerticalScrollbar( scrollState, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = AlphaScrollbar) ) .verticalScroll(scrollState) - .padding(bottom = Dimens.screenVerticalMargin) .testTag(SCROLLABLE_COLUMN_TEST_TAG) ) { - NotificationBanner( - notification = uiState.inAppNotification, - isPlayBuild = uiState.isPlayBuild, - onClickUpdateVersion = onUpdateVersionClick, - onClickShowAccount = onManageAccountClick, - onClickDismissNewDevice = onDismissNewDeviceClick, + Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) + MullvadCircularProgressIndicatorLarge( + color = MaterialTheme.colorScheme.onPrimary, + modifier = + Modifier.animateContentSize() + .padding( + start = Dimens.sideMargin, + end = Dimens.sideMargin, + top = Dimens.mediumPadding + ) + .alpha(if (uiState.showLoading) 1f else 0f) + .align(Alignment.CenterHorizontally) + .testTag(CIRCULAR_PROGRESS_INDICATOR) + .onGloballyPositioned { + val offsetY = it.positionInParent().y + it.size.height / 2 + it.parentLayoutCoordinates?.let { + progressIndicatorBias = offsetY / it.size.height.toFloat() + } + } ) - Spacer(modifier = Modifier.weight(1f)) - if ( - uiState.tunnelRealState is TunnelState.Connecting || - (uiState.tunnelRealState is TunnelState.Disconnecting && - uiState.tunnelRealState.actionAfterDisconnect == - ActionAfterDisconnect.Reconnect) - ) { - MullvadCircularProgressIndicatorLarge( - color = MaterialTheme.colorScheme.onPrimary, - modifier = - Modifier.padding( - start = Dimens.sideMargin, - end = Dimens.sideMargin, - top = Dimens.mediumPadding - ) - .align(Alignment.CenterHorizontally) - .testTag(CIRCULAR_PROGRESS_INDICATOR) - ) - } - Spacer(modifier = Modifier.height(Dimens.mediumPadding)) + Spacer(modifier = Modifier.defaultMinSize(minHeight = Dimens.mediumPadding).weight(1f)) ConnectionStatusText( state = uiState.tunnelRealState, modifier = Modifier.padding(horizontal = Dimens.sideMargin) @@ -243,9 +281,7 @@ fun ConnectScreen( var expanded by rememberSaveable { mutableStateOf(false) } LocationInfo( onToggleTunnelInfo = { expanded = !expanded }, - isVisible = - uiState.tunnelRealState !is TunnelState.Disconnected && - uiState.location?.hostname != null, + isVisible = uiState.showLocationInfo, isExpanded = expanded, location = uiState.location, inAddress = uiState.inAddress, @@ -282,6 +318,46 @@ fun ConnectScreen( connectClick = { handleThrottledAction(onConnectClick) }, reconnectButtonTestTag = RECONNECT_BUTTON_TEST_TAG ) + // 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 +fun TunnelState.toMarker(location: GeoIpLocation?): Marker? { + if (location == null) return null + return when (this) { + is TunnelState.Connected -> + Marker( + location.toLatLong(), + colors = + LocationMarkerColors(centerColor = MaterialTheme.colorScheme.inversePrimary), + ) + is TunnelState.Connecting -> null + is TunnelState.Disconnected -> + Marker( + location.toLatLong(), + colors = LocationMarkerColors(centerColor = MaterialTheme.colorScheme.error) + ) + is TunnelState.Disconnecting -> null + is TunnelState.Error -> null + } +} + +@Composable +fun TunnelState.topBarColor(): Color = + if (isSecured()) MaterialTheme.colorScheme.inversePrimary else MaterialTheme.colorScheme.error + +@Composable +fun TunnelState.iconTintColor(): Color = + if (isSecured()) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onError + } + .copy(alpha = AlphaTopBar) + +fun GeoIpLocation.toLatLong() = + LatLong(Latitude(latitude.toFloat()), Longitude(longitude.toFloat())) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt index dc26e24741df..152eed8c0e3e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/ConnectUiState.kt @@ -17,8 +17,15 @@ data class ConnectUiState( val inAppNotification: InAppNotification?, val deviceName: String?, val daysLeftUntilExpiry: Int?, - val isPlayBuild: Boolean + val isPlayBuild: Boolean, + val animateMap: Boolean ) { + + val showLocationInfo: Boolean = + tunnelRealState !is TunnelState.Disconnected && location?.hostname != null + val showLoading = + tunnelRealState is TunnelState.Connecting || tunnelRealState is TunnelState.Disconnecting + companion object { val INITIAL = ConnectUiState( @@ -32,7 +39,8 @@ data class ConnectUiState( inAppNotification = null, deviceName = null, daysLeftUntilExpiry = null, - isPlayBuild = false + isPlayBuild = false, + animateMap = true ) } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt index 4ccf15bb63f3..d58107c713f2 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/constant/AnimationConstant.kt @@ -1,6 +1,9 @@ package net.mullvad.mullvadvpn.constant import androidx.compose.animation.core.Spring +import net.mullvad.mullvadvpn.model.LatLong +import net.mullvad.mullvadvpn.model.Latitude +import net.mullvad.mullvadvpn.model.Longitude const val MINIMUM_LOADING_TIME_MILLIS = 500L @@ -9,3 +12,10 @@ const val SCREEN_ANIMATION_TIME_MILLIS = Spring.StiffnessMediumLow.toInt() const val HORIZONTAL_SLIDE_FACTOR = 1 / 3f fun Int.withHorizontalScalingFactor(): Int = (this * HORIZONTAL_SLIDE_FACTOR).toInt() + +const val SECURE_ZOOM = 1.15f +const val UNSECURE_ZOOM = 1.20f +const val SECURE_ZOOM_ANIMATION_MILLIS = 2000 + +// Location of Gothenburg, Sweden +val fallbackLatLong = LatLong(Latitude(57.7065f), Longitude(11.967f)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt index 9b5a6c1e0013..aa00e49f54b4 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/ConnectViewModel.kt @@ -129,7 +129,8 @@ class ConnectViewModel( inAppNotification = notifications.firstOrNull(), deviceName = deviceName, daysLeftUntilExpiry = accountExpiry.date()?.daysFromNow(), - isPlayBuild = isPlayBuild + isPlayBuild = isPlayBuild, + animateMap = true ) } }