Merge branch 'improve-tv-ui-experience'

This commit is contained in:
David Göransson 2025-03-19 09:38:52 +01:00
commit 22fc75dc52
No known key found for this signature in database
GPG Key ID: 6DBE540EB30E4765
51 changed files with 1029 additions and 347 deletions

View File

@ -29,6 +29,8 @@ Line wrap the file at 100 chars. Th
### Changed
- Disable Wireguard port setting when a obfuscation is selected since it is not used when an
obfuscation is applied.
- Adapt UI on Connect Screen for Android TV, including a navigation rail and redesigned in-app
notification bar.
### Removed
- Remove Google's resolvers from encrypted DNS proxy.

View File

@ -372,6 +372,8 @@ dependencies {
implementation(projects.lib.resource)
implementation(projects.lib.shared)
implementation(projects.lib.talpid)
implementation(projects.lib.tv)
implementation(projects.lib.ui.component)
implementation(projects.tile)
implementation(projects.lib.theme)
implementation(projects.service)
@ -388,6 +390,7 @@ dependencies {
implementation(libs.androidx.lifecycle.runtime)
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.tv)
implementation(libs.arrow)
implementation(libs.arrow.optics)
implementation(libs.arrow.resilience)

View File

@ -19,8 +19,6 @@ import net.mullvad.mullvadvpn.compose.state.ConnectUiState
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION
import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG
import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON
@ -28,11 +26,13 @@ import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.TransportProtocol
import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint
import net.mullvad.mullvadvpn.lib.model.TunnelState
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.lib.model.VersionInfo
import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_ACTION
import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_TEXT_ACTION
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

View File

@ -1,49 +1,25 @@
package net.mullvad.mullvadvpn.compose.component.notificationbanner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.toUpperCase
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import java.time.Duration
import net.mullvad.mullvadvpn.compose.component.MullvadTopBar
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION
import net.mullvad.mullvadvpn.compose.util.rememberPrevious
import net.mullvad.mullvadvpn.compose.util.isTv
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.VersionInfo
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.warning
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.ui.notification.StatusLevel
import net.mullvad.mullvadvpn.lib.tv.NotificationBannerTv
import net.mullvad.mullvadvpn.lib.ui.component.AnimatedNotificationBanner
@Preview
@Composable
@ -52,18 +28,17 @@ private fun PreviewNotificationBanner() {
Column(Modifier.background(color = MaterialTheme.colorScheme.surface)) {
val bannerDataList =
listOf(
InAppNotification.UnsupportedVersion(
versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false)
),
InAppNotification.AccountExpiry(expiry = Duration.ZERO),
InAppNotification.TunnelStateBlocked,
InAppNotification.NewDevice("Courageous Turtle"),
InAppNotification.TunnelStateError(
error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true)
),
InAppNotification.NewVersionChangelog,
)
.map { it.toNotificationData(false, {}, {}, {}, {}, {}) }
InAppNotification.UnsupportedVersion(
versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false)
),
InAppNotification.AccountExpiry(expiry = Duration.ZERO),
InAppNotification.TunnelStateBlocked,
InAppNotification.NewDevice("Courageous Turtle"),
InAppNotification.TunnelStateError(
error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true)
),
InAppNotification.NewVersionChangelog,
)
bannerDataList.forEach {
MullvadTopBar(
@ -72,7 +47,15 @@ private fun PreviewNotificationBanner() {
onAccountClicked = {},
iconTintColor = MaterialTheme.colorScheme.primary,
)
Notification(it)
NotificationBanner(
notification = it,
isPlayBuild = false,
openAppListing = {},
onClickShowAccount = {},
onClickShowChangelog = {},
onClickDismissChangelog = {},
onClickDismissNewDevice = {},
)
Spacer(modifier = Modifier.size(16.dp))
}
}
@ -90,163 +73,28 @@ fun NotificationBanner(
onClickDismissChangelog: () -> Unit,
onClickDismissNewDevice: () -> Unit,
) {
// Fix for animating to invisible state
val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true })
AnimatedVisibility(
visible = notification != null,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = modifier,
) {
val visibleNotification = notification ?: previous
if (visibleNotification != null)
Notification(
visibleNotification.toNotificationData(
isPlayBuild = isPlayBuild,
openAppListing,
onClickShowAccount,
onClickShowChangelog,
onClickDismissChangelog,
onClickDismissNewDevice,
)
)
}
}
@Composable
@Suppress("LongMethod")
private fun Notification(notificationBannerData: NotificationData) {
val (title, message, statusLevel, action) = notificationBannerData
ConstraintLayout(
modifier =
Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surfaceContainer)
.padding(
start = Dimens.notificationBannerStartPadding,
end = Dimens.notificationBannerEndPadding,
top = Dimens.smallPadding,
bottom = Dimens.smallPadding,
)
.animateContentSize()
.testTag(NOTIFICATION_BANNER)
) {
val (status, textTitle, textMessage, actionIcon) = createRefs()
NotificationDot(
statusLevel,
Modifier.constrainAs(status) {
top.linkTo(textTitle.top)
start.linkTo(parent.start)
bottom.linkTo(textTitle.bottom)
},
if (isTv()) {
NotificationBannerTv(
modifier = modifier,
notification = notification,
isPlayBuild = isPlayBuild,
openAppListing = openAppListing,
onClickShowAccount = onClickShowAccount,
onClickShowChangelog = onClickShowChangelog,
onClickDismissChangelog = onClickDismissChangelog,
onClickDismissNewDevice = onClickDismissNewDevice,
)
Text(
text = title.toUpperCase(),
modifier =
Modifier.constrainAs(textTitle) {
top.linkTo(parent.top)
start.linkTo(status.end)
if (message != null) {
bottom.linkTo(textMessage.top)
} else {
bottom.linkTo(parent.bottom)
}
if (action != null) {
end.linkTo(actionIcon.start)
} else {
end.linkTo(parent.end)
}
width = Dimension.fillToConstraints
}
.padding(start = Dimens.smallPadding),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
message?.let { message ->
Text(
text = message.text,
modifier =
Modifier.constrainAs(textMessage) {
top.linkTo(textTitle.bottom)
start.linkTo(textTitle.start)
if (action != null) {
end.linkTo(actionIcon.start)
bottom.linkTo(parent.bottom)
} else {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
}
width = Dimension.fillToConstraints
height = Dimension.wrapContent
}
.padding(start = Dimens.smallPadding, top = Dimens.tinyPadding)
.wrapContentWidth(Alignment.Start)
.let {
if (message is NotificationMessage.ClickableText) {
it.clickable(
onClickLabel = message.contentDescription,
role = Role.Button,
) {
message.onClick()
}
.testTag(NOTIFICATION_BANNER_TEXT_ACTION)
} else {
it
}
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
}
action?.let {
NotificationAction(
it.icon,
onClick = it.onClick,
contentDescription = it.contentDescription,
modifier =
Modifier.constrainAs(actionIcon) {
top.linkTo(parent.top)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
)
}
}
}
@Composable
private fun NotificationDot(statusLevel: StatusLevel, modifier: Modifier) {
Box(
modifier =
modifier
.background(
color =
when (statusLevel) {
StatusLevel.Error -> MaterialTheme.colorScheme.error
StatusLevel.Warning -> MaterialTheme.colorScheme.warning
StatusLevel.Info -> MaterialTheme.colorScheme.tertiary
},
shape = CircleShape,
)
.size(Dimens.notificationStatusIconSize)
)
}
@Composable
private fun NotificationAction(
imageVector: ImageVector,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(modifier = modifier.testTag(NOTIFICATION_BANNER_ACTION), onClick = onClick) {
Icon(
modifier = Modifier.padding(Dimens.notificationIconPadding),
imageVector = imageVector,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurface,
} else {
AnimatedNotificationBanner(
modifier = modifier,
notificationModifier = Modifier.fillMaxWidth(),
notification = notification,
isPlayBuild = isPlayBuild,
openAppListing = openAppListing,
onClickShowAccount = onClickShowAccount,
onClickShowChangelog = onClickShowChangelog,
onClickDismissChangelog = onClickDismissChangelog,
onClickDismissNewDevice = onClickDismissNewDevice,
)
}
}

View File

@ -15,11 +15,11 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator
import com.ramcosta.composedestinations.spec.DestinationStyle
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.textResource
import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
import net.mullvad.mullvadvpn.compose.preview.DevicePreviewParameterProvider
import net.mullvad.mullvadvpn.lib.model.Device
import net.mullvad.mullvadvpn.lib.model.DeviceId
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.ui.component.toAnnotatedString
@Preview
@Composable

View File

@ -24,10 +24,10 @@ import androidx.core.text.HtmlCompat
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
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.ui.component.toAnnotatedString
@Preview
@Composable

View File

@ -1,7 +1,7 @@
package net.mullvad.mullvadvpn.compose.preview
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.lib.model.VersionInfo
import net.mullvad.mullvadvpn.viewmodel.AppInfoUiState
class AppInfoUiStatePreviewParameterProvider : PreviewParameterProvider<AppInfoUiState> {

View File

@ -5,6 +5,7 @@ import java.net.InetAddress
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.InAppNotification
class ConnectUiStatePreviewParameterProvider : PreviewParameterProvider<ConnectUiState> {
override val values = sequenceOf(ConnectUiState.INITIAL) + generateOtherStates()
@ -29,7 +30,7 @@ private fun generateOtherStates(): Sequence<ConnectUiState> =
),
TunnelStatePreviewData.generateErrorState(isBlocking = true),
)
.map { state ->
.mapIndexed { index, state ->
ConnectUiState(
location =
GeoIpLocation(
@ -45,7 +46,8 @@ private fun generateOtherStates(): Sequence<ConnectUiState> =
selectedRelayItemTitle = "Relay Title",
tunnelState = state,
showLocation = true,
inAppNotification = null,
inAppNotification =
if (index == 0) InAppNotification.NewDevice("Test Device") else null,
deviceName = "Cool Beans",
daysLeftUntilExpiry = 42,
isPlayBuild = true,

View File

@ -54,13 +54,13 @@ import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.component.NavigateBackIconButton
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithLargeTopBarAndButton
import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
import net.mullvad.mullvadvpn.compose.transitions.SlideInFromRightTransition
import net.mullvad.mullvadvpn.lib.common.util.openVpnSettings
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible
import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
import net.mullvad.mullvadvpn.lib.ui.component.toAnnotatedString
import net.mullvad.mullvadvpn.service.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.util.appendHideNavOnPlayBuild

View File

@ -10,6 +10,7 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.calculateEndPadding
@ -25,6 +26,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -71,6 +74,7 @@ import net.mullvad.mullvadvpn.compose.button.SwitchLocationButton
import net.mullvad.mullvadvpn.compose.component.ConnectionStatusText
import net.mullvad.mullvadvpn.compose.component.ExpandChevron
import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge
import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar
import net.mullvad.mullvadvpn.compose.component.ScaffoldWithTopBarAndDeviceName
import net.mullvad.mullvadvpn.compose.component.connectioninfo.ConnectionDetailPanel
import net.mullvad.mullvadvpn.compose.component.connectioninfo.FeatureIndicatorsPanel
@ -89,6 +93,7 @@ import net.mullvad.mullvadvpn.compose.transitions.HomeTransition
import net.mullvad.mullvadvpn.compose.util.CollectSideEffectWithLifecycle
import net.mullvad.mullvadvpn.compose.util.CreateVpnProfile
import net.mullvad.mullvadvpn.compose.util.OnNavResultValue
import net.mullvad.mullvadvpn.compose.util.isTv
import net.mullvad.mullvadvpn.compose.util.showSnackbarImmediately
import net.mullvad.mullvadvpn.constant.SECURE_ZOOM
import net.mullvad.mullvadvpn.constant.SECURE_ZOOM_ANIMATION_MILLIS
@ -115,6 +120,7 @@ import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible
import net.mullvad.mullvadvpn.lib.theme.typeface.connectionStatus
import net.mullvad.mullvadvpn.lib.theme.typeface.hostname
import net.mullvad.mullvadvpn.lib.tv.NavigationDrawerTv
import net.mullvad.mullvadvpn.util.removeHtmlTags
import net.mullvad.mullvadvpn.viewmodel.ConnectViewModel
import org.koin.androidx.compose.koinViewModel
@ -267,71 +273,130 @@ fun ConnectScreen(
onAccountClick: () -> Unit,
onDismissNewDeviceClick: () -> Unit,
) {
ScaffoldWithTopBarAndDeviceName(
topBarColor = state.tunnelState.topBarColor(),
iconTintColor = state.tunnelState.iconTintColor(),
onSettingsClicked = onSettingsClick,
onAccountClicked = onAccountClick,
deviceName = state.deviceName,
timeLeft = state.daysLeftUntilExpiry,
snackbarHostState = snackbarHostState,
) {
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val indicatorPercentOffset =
if (screenHeight < SCREEN_HEIGHT_THRESHOLD) SHORT_SCREEN_INDICATOR_BIAS
else TALL_SCREEN_INDICATOR_BIAS
Box(
Modifier.padding(
top = it.calculateTopPadding(),
start = it.calculateStartPadding(LocalLayoutDirection.current),
end = it.calculateEndPadding(LocalLayoutDirection.current),
)
.fillMaxSize()
) {
MullvadMap(state, indicatorPercentOffset)
MullvadCircularProgressIndicatorLarge(
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
x = (constraints.maxWidth * 0.5f - placeable.width / 2).toInt(),
y =
(constraints.maxHeight * indicatorPercentOffset -
placeable.height / 2)
.toInt(),
)
}
}
.alpha(if (state.showLoading) AlphaVisible else AlphaInvisible)
.testTag(CIRCULAR_PROGRESS_INDICATOR),
val content =
@Composable { padding: PaddingValues ->
Content(
padding,
state,
onDisconnectClick,
onReconnectClick,
onConnectClick,
onCancelClick,
onSwitchLocationClick,
onOpenAppListing,
onManageAccountClick,
onChangelogClick,
onDismissChangelogClick,
onDismissNewDeviceClick,
)
}
Box(modifier = Modifier.fillMaxSize().padding(bottom = it.calculateBottomPadding())) {
NotificationBanner(
notification = state.inAppNotification,
isPlayBuild = state.isPlayBuild,
openAppListing = onOpenAppListing,
onClickShowAccount = onManageAccountClick,
onClickShowChangelog = onChangelogClick,
onClickDismissChangelog = onDismissChangelogClick,
onClickDismissNewDevice = onDismissNewDeviceClick,
)
ConnectionCard(
state = state,
modifier = Modifier.align(Alignment.BottomCenter),
onSwitchLocationClick = onSwitchLocationClick,
onDisconnectClick = onDisconnectClick,
onReconnectClick = onReconnectClick,
onCancelClick = onCancelClick,
onConnectClick = onConnectClick,
if (isTv()) {
Scaffold(
snackbarHost = {
SnackbarHost(
snackbarHostState,
snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) },
)
}
) {
NavigationDrawerTv(
daysLeftUntilExpiry = state.daysLeftUntilExpiry,
deviceName = state.deviceName,
onSettingsClick = onSettingsClick,
onAccountClick = onAccountClick,
) {
content(it)
}
}
} else {
ScaffoldWithTopBarAndDeviceName(
topBarColor = state.tunnelState.topBarColor(),
iconTintColor = state.tunnelState.iconTintColor(),
onSettingsClicked = onSettingsClick,
onAccountClicked = onAccountClick,
deviceName = state.deviceName,
timeLeft = state.daysLeftUntilExpiry,
snackbarHostState = snackbarHostState,
) {
content(it)
}
}
}
@Composable
private fun Content(
paddingValues: PaddingValues,
state: ConnectUiState,
onDisconnectClick: () -> Unit,
onReconnectClick: () -> Unit,
onConnectClick: () -> Unit,
onCancelClick: () -> Unit,
onSwitchLocationClick: () -> Unit,
onOpenAppListing: () -> Unit,
onManageAccountClick: () -> Unit,
onChangelogClick: () -> Unit,
onDismissChangelogClick: () -> Unit,
onDismissNewDeviceClick: () -> Unit,
) {
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val indicatorPercentOffset =
if (screenHeight < SCREEN_HEIGHT_THRESHOLD) SHORT_SCREEN_INDICATOR_BIAS
else TALL_SCREEN_INDICATOR_BIAS
Box(
Modifier.padding(
top = paddingValues.calculateTopPadding(),
start = paddingValues.calculateStartPadding(LocalLayoutDirection.current),
end = paddingValues.calculateEndPadding(LocalLayoutDirection.current),
)
.fillMaxSize()
) {
MullvadMap(state, indicatorPercentOffset)
MullvadCircularProgressIndicatorLarge(
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
placeable.placeRelative(
x = (constraints.maxWidth * 0.5f - placeable.width / 2).toInt(),
y =
(constraints.maxHeight * indicatorPercentOffset -
placeable.height / 2)
.toInt(),
)
}
}
.alpha(if (state.showLoading) AlphaVisible else AlphaInvisible)
.testTag(CIRCULAR_PROGRESS_INDICATOR),
)
Box(
modifier =
Modifier.fillMaxSize().padding(bottom = paddingValues.calculateBottomPadding())
) {
NotificationBanner(
modifier = Modifier.align(Alignment.TopCenter),
notification = state.inAppNotification,
isPlayBuild = state.isPlayBuild,
openAppListing = onOpenAppListing,
onClickShowAccount = onManageAccountClick,
onClickShowChangelog = onChangelogClick,
onClickDismissChangelog = onDismissChangelogClick,
onClickDismissNewDevice = onDismissNewDeviceClick,
)
ConnectionCard(
state = state,
modifier = Modifier.align(Alignment.BottomCenter),
onSwitchLocationClick = onSwitchLocationClick,
onDisconnectClick = onDisconnectClick,
onReconnectClick = onReconnectClick,
onCancelClick = onCancelClick,
onConnectClick = onConnectClick,
)
}
}
}

View File

@ -1,8 +1,8 @@
package net.mullvad.mullvadvpn.compose.state
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.TunnelState
import net.mullvad.mullvadvpn.repository.InAppNotification
data class ConnectUiState(
val location: GeoIpLocation?,

View File

@ -49,11 +49,6 @@ const val LOCATION_INFO_TEST_TAG = "location_info_test_tag"
const val LOCATION_INFO_CONNECTION_IN_TEST_TAG = "location_info_connection_in_test_tag"
const val LOCATION_INFO_CONNECTION_OUT_TEST_TAG = "location_info_connection_out_test_tag"
// ConnectScreen - Notification banner
const val NOTIFICATION_BANNER = "notification_banner"
const val NOTIFICATION_BANNER_ACTION = "notification_banner_action"
const val NOTIFICATION_BANNER_TEXT_ACTION = "notification_banner_text_action"
// PlayPayment
const val PLAY_PAYMENT_INFO_ICON_TEST_TAG = "play_payment_info_icon_test_tag"

View File

@ -0,0 +1,13 @@
package net.mullvad.mullvadvpn.compose.util
import android.content.pm.PackageManager
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.booleanResource
import net.mullvad.mullvadvpn.R
@Composable
fun isTv(): Boolean {
return booleanResource(R.bool.isTv) ||
LocalContext.current.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}

View File

@ -1,60 +1,16 @@
package net.mullvad.mullvadvpn.repository
import java.time.Duration
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase
import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase
import net.mullvad.mullvadvpn.usecase.NewDeviceNotificationUseCase
import net.mullvad.mullvadvpn.usecase.TunnelStateNotificationUseCase
import net.mullvad.mullvadvpn.usecase.VersionNotificationUseCase
enum class StatusLevel {
Error,
Warning,
Info,
}
sealed class InAppNotification {
abstract val statusLevel: StatusLevel
abstract val priority: Long
data class TunnelStateError(val error: ErrorState) : InAppNotification() {
override val statusLevel = StatusLevel.Error
override val priority: Long = 1001
}
data object TunnelStateBlocked : InAppNotification() {
override val statusLevel = StatusLevel.Error
override val priority: Long = 1000
}
data class UnsupportedVersion(val versionInfo: VersionInfo) : InAppNotification() {
override val statusLevel = StatusLevel.Error
override val priority: Long = 999
}
data class AccountExpiry(val expiry: Duration) : InAppNotification() {
override val statusLevel = StatusLevel.Warning
override val priority: Long = 1001
}
data class NewDevice(val deviceName: String) : InAppNotification() {
override val statusLevel = StatusLevel.Info
override val priority: Long = 1001
}
data object NewVersionChangelog : InAppNotification() {
override val statusLevel = StatusLevel.Info
override val priority: Long = 1001
}
}
class InAppNotificationController(
accountExpiryInAppNotificationUseCase: AccountExpiryInAppNotificationUseCase,
newDeviceNotificationUseCase: NewDeviceNotificationUseCase,

View File

@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
import net.mullvad.mullvadvpn.lib.model.BuildVersion
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.lib.model.VersionInfo
class AppVersionInfoRepository(
private val buildVersion: BuildVersion,

View File

@ -5,8 +5,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD
import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL
import net.mullvad.mullvadvpn.service.notifications.accountexpiry.AccountExpiryTicker

View File

@ -2,8 +2,8 @@ package net.mullvad.mullvadvpn.usecase
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.repository.ChangelogRepository
import net.mullvad.mullvadvpn.repository.InAppNotification
class NewChangelogNotificationUseCase(private val changelogRepository: ChangelogRepository) {
operator fun invoke() =

View File

@ -3,8 +3,8 @@ package net.mullvad.mullvadvpn.usecase
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.repository.NewDeviceRepository
class NewDeviceNotificationUseCase(

View File

@ -4,9 +4,9 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.TunnelState
import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy
import net.mullvad.mullvadvpn.repository.InAppNotification
class TunnelStateNotificationUseCase(private val connectionProxy: ConnectionProxy) {
operator fun invoke(): Flow<List<InAppNotification>> =

View File

@ -2,8 +2,8 @@ package net.mullvad.mullvadvpn.usecase
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.VersionInfo
import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository
class VersionNotificationUseCase(

View File

@ -14,8 +14,8 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.lib.model.VersionInfo
import net.mullvad.mullvadvpn.repository.ChangelogRepository
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository
class AppInfoViewModel(

View File

@ -15,7 +15,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.usecase.AccountExpiryInAppNotificationUseCase
import net.mullvad.mullvadvpn.usecase.NewChangelogNotificationUseCase

View File

@ -17,8 +17,8 @@ import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.AccountData
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_CLOSE_TO_EXPIRY_THRESHOLD
import net.mullvad.mullvadvpn.service.notifications.accountexpiry.ACCOUNT_EXPIRY_IN_APP_NOTIFICATION_UPDATE_INTERVAL
import org.junit.jupiter.api.AfterEach

View File

@ -16,8 +16,8 @@ import net.mullvad.mullvadvpn.lib.model.AccountNumber
import net.mullvad.mullvadvpn.lib.model.Device
import net.mullvad.mullvadvpn.lib.model.DeviceId
import net.mullvad.mullvadvpn.lib.model.DeviceState
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.repository.NewDeviceRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach

View File

@ -12,9 +12,9 @@ import kotlinx.coroutines.test.runTest
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.TunnelState
import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy
import net.mullvad.mullvadvpn.repository.InAppNotification
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

View File

@ -10,8 +10,8 @@ import kotlin.test.assertTrue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.VersionInfo
import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach

View File

@ -24,6 +24,7 @@ import net.mullvad.mullvadvpn.lib.model.AccountData
import net.mullvad.mullvadvpn.lib.model.DeviceState
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint
import net.mullvad.mullvadvpn.lib.model.TunnelState
import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken
@ -31,7 +32,6 @@ import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.repository.ChangelogRepository
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.repository.InAppNotificationController
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager
import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionState

View File

@ -13,11 +13,11 @@ import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
import net.mullvad.mullvadvpn.lib.model.Constraint
import net.mullvad.mullvadvpn.lib.model.DeviceState
import net.mullvad.mullvadvpn.lib.model.Settings
import net.mullvad.mullvadvpn.lib.model.VersionInfo
import net.mullvad.mullvadvpn.lib.model.WireguardConstraints
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.repository.SettingsRepository
import net.mullvad.mullvadvpn.repository.WireguardConstraintsRepository
import net.mullvad.mullvadvpn.ui.VersionInfo
import net.mullvad.mullvadvpn.ui.serviceconnection.AppVersionInfoRepository
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach

View File

@ -22,6 +22,7 @@ androidx-testmonitor = "1.7.2"
androidx-testorchestrator = "1.5.1"
androidx-testrunner = "1.6.2"
androidx-uiautomator = "2.4.0-alpha01"
androidx-tv = "1.0.0"
# Arrow
arrow = "2.0.1"
@ -31,6 +32,7 @@ compose = "1.7.8"
compose-destinations = "2.1.0"
compose-constraintlayout = "1.1.1"
compose-material3 = "1.3.1"
compose-material-tv = "1.1.0-alpha01"
# Update suppression for 'InvalidPackage' in config/lint.xml
grpc = "1.71.0"
@ -99,6 +101,7 @@ androidx-test-runner = { module = "androidx.test:runner", version.ref = "android
androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidx-test" }
androidx-test-uiautomator = { module = "androidx.test.uiautomator:uiautomator", version.ref = "androidx-uiautomator" }
androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidx-testorchestrator" }
androidx-tv = { module = "androidx.tv:tv-material", version.ref = "androidx-tv" }
# Arrow
arrow = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" }

View File

@ -200,6 +200,7 @@
<trusting group="androidx.lifecycle"/>
<trusting group="androidx.navigation"/>
<trusting group="androidx.profileinstaller" name="profileinstaller"/>
<trusting group="androidx.tv" name="tv-material"/>
<trusting group="^androidx[.]compose($|([.].*))" regex="true"/>
<trusting group="^androidx[.]test($|([.].*))" regex="true"/>
<trusting group="^com[.]android($|([.].*))" regex="true"/>
@ -654,6 +655,11 @@
<sha256 value="e98defdf92ca1fcbeaf16e78a60c18052b01340da3849b93e25e944f06a4e527" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation" version="1.6.8">
<artifact name="animation-1.6.8.module">
<sha256 value="31e6783f9a1de6e021942c5be1f1d777e330bfe017f5429032a24f4c3a940726" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation" version="1.7.0">
<artifact name="animation-1.7.0.module">
<sha256 value="de5277c940808643cc4928d33ffd3a20c0d1da49c1f985bf57f4edbe9f0ed625" origin="Generated by Gradle"/>
@ -678,6 +684,11 @@
<sha256 value="a2773807796a9ea149e4d567d3e4746bc3b2514147a180b00be2ca307f732c4d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-android" version="1.6.8">
<artifact name="animation-android-1.6.8.module">
<sha256 value="4f2718c77ef295fbbba8a92b0deaf6d97fc21d0bcd1753d9cba3c20b25c4a076" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-android" version="1.7.2">
<artifact name="animation-android-1.7.2.module">
<sha256 value="7f4773c5800c09adb41f37a266effaac1618737eeadae80310a771a37cd8b547" origin="Generated by Gradle"/>
@ -706,6 +717,11 @@
<sha256 value="61bfa244fb1979bcca6dcb80a5c040115aecbad84d7d2e29cf237b483cab2248" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-core" version="1.6.8">
<artifact name="animation-core-1.6.8.module">
<sha256 value="73e54651dbbfec4641840b3f3b7ad477833132cd336de2a22cc93ab233d3bd5c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-core" version="1.7.0">
<artifact name="animation-core-1.7.0.module">
<sha256 value="f47f2eeea6677efe6e54516ece48c9835810dc4a8581fe530761463a602cd239" origin="Generated by Gradle"/>
@ -735,6 +751,11 @@
<sha256 value="c571f43b5780fb80f42e85644e0ba5696af088d7228f3251c3e1d64c3bd64376" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-core-android" version="1.6.8">
<artifact name="animation-core-android-1.6.8.module">
<sha256 value="2814fcf1645cb1d5782b216236b99a4e2dde5bdcbb8e815f4514c044c28b2bef" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-core-android" version="1.7.8">
<artifact name="animation-core-android-1.7.8.module">
<sha256 value="3ce792d09bcf03a1cc1d55f1424822f0961ec9bb019450d2f036c7af1205a10c" origin="Generated by Gradle"/>
@ -756,6 +777,11 @@
<sha256 value="7d45e05892d2b9a14341e56e40c090bf9970128ab25c0d2140ba51a0389ce639" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-desktop" version="1.6.8">
<artifact name="animation-desktop-1.6.8.module">
<sha256 value="1ab7498b6321262d6c42611a8d9034d950bc11ede7eac09b7e2fc6bf97dbf8c2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.animation" name="animation-jvmstubs" version="1.7.8">
<artifact name="animation-jvmstubs-1.7.8.jar">
<sha256 value="085168cfa19c0bf96ce0e1ef5bd2586997555d706ae2572d522aef141cb4d335" origin="Generated by Gradle"/>
@ -764,6 +790,11 @@
<sha256 value="9a3c2e37dd9715444b0c55358dbe1bdca4d042e6d5071e512a029d3c65bda225" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation" version="1.6.8">
<artifact name="foundation-1.6.8.module">
<sha256 value="045615477691111fefca60926d3657707d4af3dc5d0221a9cfbbe9cf92399699" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation" version="1.7.0">
<artifact name="foundation-1.7.0.module">
<sha256 value="8462ad30b9671fb6afd6c757c9b0830f66bb13ff0ca4d28e554e7b151a1c7291" origin="Generated by Gradle"/>
@ -780,6 +811,11 @@
<sha256 value="40c973f6464c280219e3e96443c5c6d536e84a077a8d493d55a545b3869744bd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-android" version="1.6.8">
<artifact name="foundation-android-1.6.8.module">
<sha256 value="b92502e46bc91f147a8569758db9430bba34e6af477068c3cfea1207f872ab27" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-android" version="1.7.8">
<artifact name="foundation-android-1.7.8.module">
<sha256 value="dc733df156257961210fca6e715d1b5406afd2ed92989e44ed44366fc395bb1e" origin="Generated by Gradle"/>
@ -796,6 +832,11 @@
<sha256 value="a693be7c9683ab7a2932de9624047f92457a7543546c8f624f1e6528df892505" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-layout" version="1.6.8">
<artifact name="foundation-layout-1.6.8.module">
<sha256 value="7563a54ddec275c1428c3aed8ff4627a5e2ab405051e81b126e3c1c99502aa11" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-layout" version="1.7.0">
<artifact name="foundation-layout-1.7.0.module">
<sha256 value="7846029e463db74321494031fbfd0d1cd95d269c8bf586213bca191052e63876" origin="Generated by Gradle"/>
@ -820,6 +861,11 @@
<sha256 value="4094e5a3626380b22b684086e548be51afe18e7979f46d261d4f1e0b6898fb39" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-layout-android" version="1.6.8">
<artifact name="foundation-layout-android-1.6.8.module">
<sha256 value="f7769c1f05d0361bc0258291baea6865bba438bebd090ce21ff5ec1771b58c71" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-layout-android" version="1.7.0">
<artifact name="foundation-layout-android-1.7.0.module">
<sha256 value="4f6877bd205bd23a54f6f8209b07333fb878fcbd5187ba9a9881b298cd12b8a8" origin="Generated by Gradle"/>
@ -838,6 +884,11 @@
<sha256 value="a83233e768e30aab870e6667277ec91dd40adb5663d32f36dfa5dbad367db561" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-layout-desktop" version="1.6.8">
<artifact name="foundation-layout-desktop-1.6.8.module">
<sha256 value="47a7bcfbd1473204da930c9a3611e967ef1953cbfdae64e785863012573e7487" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.foundation" name="foundation-layout-jvmstubs" version="1.7.8">
<artifact name="foundation-layout-jvmstubs-1.7.8.jar">
<sha256 value="c03f5dca426beedbdfdf53abcf90ca5445dc6eb354688db763078c47f74bb5e3" origin="Generated by Gradle"/>
@ -859,6 +910,11 @@
<sha256 value="bf08f6b522f64eef4467d56e566951039eae55b54c37023712a81d4dfb214bdd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material" version="1.6.8">
<artifact name="material-1.6.8.module">
<sha256 value="85760ca4ac3b28d2e869669f042ac41976da2ad16ea17e2f280c2ae1b6f7eebd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-android" version="1.6.0">
<artifact name="material-android-1.6.0.module">
<sha256 value="923f5d7b44d423605193b651023f1b4fd7e1d8cf763bd64942b80122ff7e2885" origin="Generated by Gradle"/>
@ -883,6 +939,14 @@
<sha256 value="951f2a3a6c0913819dfaae7c69cb8cdf977f7c79bd53fef03e4faf459ee30a0f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core" version="1.6.8">
<artifact name="material-icons-core-1.6.8.module">
<sha256 value="ea1acaa1dfd488d6cab2d9d645010892e784b10b592adbe8290c5a8aaaf1944e" origin="Generated by Gradle"/>
</artifact>
<artifact name="material-icons-core-metadata-1.6.8.jar">
<sha256 value="951f2a3a6c0913819dfaae7c69cb8cdf977f7c79bd53fef03e4faf459ee30a0f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core" version="1.7.8">
<artifact name="material-icons-core-1.7.8.module">
<sha256 value="f9d63655bac19ff7f27abf68a9c0f38f5e42c85e365655b990e6e1a317f92e2f" origin="Generated by Gradle"/>
@ -899,6 +963,11 @@
<sha256 value="0400755a3aa7270893445a18cd845e35064c9de02c1c41cf2083ad4724bcac6f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core-android" version="1.6.8">
<artifact name="material-icons-core-android-1.6.8.module">
<sha256 value="8e0540fb09dd3a483168488cceec4806e4cc0a7946354a64c72c61e63fd415ff" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core-android" version="1.7.8">
<artifact name="material-icons-core-android-1.7.8.module">
<sha256 value="99a1ca83e54261a65eb96d44ea02fae43588be45ade5e97963d73e8489ea4a54" origin="Generated by Gradle"/>
@ -907,6 +976,11 @@
<sha256 value="332c06b25e662cc417fb087e76b8faa5cb249f4992ffa3360084a3d4ab882284" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core-desktop" version="1.6.8">
<artifact name="material-icons-core-desktop-1.6.8.module">
<sha256 value="898008d26735f253b40fef3fc1b66d34d6d593706e679b4f21d0ce6e1ad1c75a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core-desktop" version="1.7.8">
<artifact name="material-icons-core-desktop-1.7.8.jar">
<sha256 value="b5729220e242132b22b0c0317a304ff167a05cc685c3e9e6483d5dfca3495f56" origin="Generated by Gradle"/>
@ -995,6 +1069,11 @@
<sha256 value="89a673451d542de4819c0da6c0c680f5cc15d93e234eb67a3feed3a1a774d348" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.runtime" name="runtime" version="1.6.8">
<artifact name="runtime-1.6.8.module">
<sha256 value="15d27ca9a22e02345d2193c1d1ab509f77c714c6b3533df1a5a2c268e667b097" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.runtime" name="runtime" version="1.7.0">
<artifact name="runtime-1.7.0.module">
<sha256 value="7b9351b0ff6df9276d02d40f12765ae55bf5c6dfb8ff8df4c77dfca138fb9fc1" origin="Generated by Gradle"/>
@ -1118,6 +1197,11 @@
<sha256 value="56366721d8a87924773839c2725e72ef252da662b306b9ed662afd64b6508e6e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-graphics" version="1.6.8">
<artifact name="ui-graphics-1.6.8.module">
<sha256 value="eb4d03821a8d2c7919743a8b80ba9f96186c6022bd7ecec02940bc1555abee20" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-graphics" version="1.7.8">
<artifact name="ui-graphics-1.7.8.module">
<sha256 value="809d8da1b129997269435728c351a7939260d9acd28e2ccb9f7400eec2ec2d56" origin="Generated by Gradle"/>
@ -1126,6 +1210,11 @@
<sha256 value="5ba9ece097ba31fea936095e1c34b1f517645a1af55e4a641e707c583e61d94b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-graphics-android" version="1.6.8">
<artifact name="ui-graphics-android-1.6.8.module">
<sha256 value="39bece706d28b44b3fb2a3ae5b1508f3247ba35d5d405d96033378c98e615965" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-graphics-android" version="1.7.8">
<artifact name="ui-graphics-android-1.7.8.module">
<sha256 value="fafcba9f63c7bbd89304e7dae09327b2f2ae9ae1a3676b0fe60469403b973dcd" origin="Generated by Gradle"/>
@ -1195,6 +1284,11 @@
<sha256 value="c830a2fdf5cf174da80bbcaf977b69a4f55dd935dce78bab676e191780aa449a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-text" version="1.6.8">
<artifact name="ui-text-1.6.8.module">
<sha256 value="0fae7dc013e91f4792b34b13b36416684cd1750cbb360e498d09e79bd7ce4af7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-text" version="1.7.8">
<artifact name="ui-text-1.7.8.module">
<sha256 value="1ec485dbc361a0b132a0c01b441d42932ee207542ca4f8d82fb3e2a1560e3143" origin="Generated by Gradle"/>
@ -1203,6 +1297,11 @@
<sha256 value="5bd379bebabeb47ea92b3777bb1ceb31dc93c294b46f264dcf8e4c010f4699fc" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-text-android" version="1.6.8">
<artifact name="ui-text-android-1.6.8.module">
<sha256 value="31c5457679534b6bdeaffa0071266614f6fb9af0b7119928427eb2fb00ff6748" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-text-android" version="1.7.8">
<artifact name="ui-text-android-1.7.8.module">
<sha256 value="1fa981ca4d73faea7079a722564237f05465ebb257e81509895aebf207392331" origin="Generated by Gradle"/>
@ -1325,6 +1424,11 @@
<sha256 value="85b335a307cb203c0ee477bfeada5023aadc282bd8ab9d114d69d61937490e17" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-util" version="1.6.8">
<artifact name="ui-util-1.6.8.module">
<sha256 value="c91b6fe99cd05baae7c6858dc0d52dd643c9ce3edf15308b51b6c6e6bb22873c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-util" version="1.7.8">
<artifact name="ui-util-1.7.8.module">
<sha256 value="c6489709dad543bdfee77f42784a7b26284ce913e2e8cf33554ad2ed79c7dea7" origin="Generated by Gradle"/>
@ -1333,6 +1437,11 @@
<sha256 value="25afa139ccbda2c33c6d9e7be3579e2ca9295f986e3b4f5b96297d6ce0fba86a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-util-android" version="1.6.8">
<artifact name="ui-util-android-1.6.8.module">
<sha256 value="c6fccb2b21c7187a0905c5b667939680c8996b7cbe0c33a8c8a477a2f6b5e53c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-util-android" version="1.7.8">
<artifact name="ui-util-android-1.7.8.module">
<sha256 value="a18c49781d7324410966e1faa76dd165a874474a13f8bb0c6f2214b50bbb4d6c" origin="Generated by Gradle"/>
@ -2437,6 +2546,14 @@
<sha256 value="d0d8d486b6bd33206dbf3f1a6d167e9b43c268ea63c3321c886b1543ad05ece3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.tv" name="tv-material" version="1.0.0">
<artifact name="tv-material-1.0.0.aar">
<sha256 value="a66890ad58ffc31036d8a9ea99fa3ab478c5daa7189b296c6b92e05e8c7db604" origin="Generated by Gradle"/>
</artifact>
<artifact name="tv-material-1.0.0.module">
<sha256 value="fa6689598785362efcb66a328907ecead533c7fd79f7d52c649aefa52841400d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.vectordrawable" name="vectordrawable" version="1.1.0">
<artifact name="vectordrawable-1.1.0.aar">
<sha256 value="46fd633ac01b49b7fcabc263bf098c5a8b9e9a69774d234edcca04fb02df8e26" origin="Generated by Gradle"/>

View File

@ -0,0 +1,44 @@
package net.mullvad.mullvadvpn.lib.model
import java.time.Duration
enum class StatusLevel {
Error,
Warning,
Info,
}
sealed class InAppNotification {
abstract val statusLevel: StatusLevel
abstract val priority: Long
data class TunnelStateError(val error: ErrorState) : InAppNotification() {
override val statusLevel = StatusLevel.Error
override val priority: Long = 1001
}
data object TunnelStateBlocked : InAppNotification() {
override val statusLevel = StatusLevel.Error
override val priority: Long = 1000
}
data class UnsupportedVersion(val versionInfo: VersionInfo) : InAppNotification() {
override val statusLevel = StatusLevel.Error
override val priority: Long = 999
}
data class AccountExpiry(val expiry: Duration) : InAppNotification() {
override val statusLevel = StatusLevel.Warning
override val priority: Long = 1001
}
data class NewDevice(val deviceName: String) : InAppNotification() {
override val statusLevel = StatusLevel.Info
override val priority: Long = 1001
}
data object NewVersionChangelog : InAppNotification() {
override val statusLevel = StatusLevel.Info
override val priority: Long = 1001
}
}

View File

@ -1,3 +1,3 @@
package net.mullvad.mullvadvpn.ui
package net.mullvad.mullvadvpn.lib.model
data class VersionInfo(val currentVersion: String, val isSupported: Boolean)

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="isTv">true</bool>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="isTv">false</bool>
</resources>

View File

@ -40,9 +40,12 @@ data class Dimensions(
val largePadding: Dp = 32.dp,
val listIconSize: Dp = 24.dp,
val listItemDivider: Dp = 1.dp,
val mediumIconSize: Dp = 32.dp,
val mediumPadding: Dp = 16.dp,
val mediumSpacer: Dp = 16.dp,
val miniPadding: Dp = 4.dp,
val mullvadLogoTextStartPadding: Dp = 6.dp,
val mullvadLogoTextHeight: Dp = 13.dp,
val notificationBannerEndPadding: Dp = 8.dp,
val notificationBannerStartPadding: Dp = 16.dp,
val notificationEndIconPadding: Dp = 4.dp,
@ -75,6 +78,9 @@ data class Dimensions(
val tinyPadding: Dp = 4.dp,
val titleIconSize: Dp = 48.dp,
val topPadding: Dp = 20.dp,
val tvDrawerHorizontalPadding: Dp = 12.dp,
val tvDrawerHeaderStartPadding: Dp = 12.dp,
val tvDrawerHeaderWithFocusStartPadding: Dp = 16.dp,
val verticalDividerPadding: Dp = 12.dp,
val verticalSpace: Dp = 20.dp,
val verticalSpacer: Dp = 1.dp,

View File

@ -0,0 +1,48 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose)
}
android {
namespace = "net.mullvad.mullvadvpn.lib.tv"
compileSdk = Versions.compileSdkVersion
buildToolsVersion = Versions.buildToolsVersion
defaultConfig { minSdk = Versions.minSdkVersion }
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = Versions.jvmTarget
allWarningsAsErrors = true
}
lint {
lintConfig = file("${rootProject.projectDir}/config/lint.xml")
abortOnError = true
warningsAsErrors = true
}
}
dependencies {
implementation(libs.kotlin.stdlib)
implementation(libs.androidx.tv)
implementation(libs.androidx.activity.compose)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(projects.lib.model)
implementation(projects.lib.resource)
implementation(projects.lib.shared)
implementation(projects.lib.theme)
implementation(projects.lib.ui.component)
// UI tooling
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
}

View File

@ -0,0 +1 @@
<manifest />

View File

@ -0,0 +1,251 @@
package net.mullvad.mullvadvpn.lib.tv
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateDpAsState
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester.Companion.Cancel
import androidx.compose.ui.focus.FocusRequester.Companion.Default
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.tv.material3.DrawerValue
import androidx.tv.material3.ModalNavigationDrawer
import androidx.tv.material3.NavigationDrawerItem
import androidx.tv.material3.NavigationDrawerItemDefaults
import androidx.tv.material3.NavigationDrawerScope
import androidx.tv.material3.rememberDrawerState
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
private class DrawerValueProvider : PreviewParameterProvider<DrawerValue> {
override val values: Sequence<DrawerValue>
get() = sequenceOf(DrawerValue.Closed, DrawerValue.Open)
}
@Preview("Closed|Open")
@Composable
fun PreviewNavigationDrawerTvClosed(
@PreviewParameter(DrawerValueProvider::class) drawerValue: DrawerValue
) {
AppTheme {
NavigationDrawerTv(
daysLeftUntilExpiry = 30,
deviceName = "Cool Cat",
initialDrawerValue = drawerValue,
onSettingsClick = {},
onAccountClick = {},
) {}
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
@Suppress("LongMethod")
fun NavigationDrawerTv(
daysLeftUntilExpiry: Long?,
deviceName: String?,
initialDrawerValue: DrawerValue = DrawerValue.Closed,
onSettingsClick: (() -> Unit),
onAccountClick: (() -> Unit),
content: @Composable () -> Unit,
) {
val drawerState = rememberDrawerState(initialDrawerValue)
val focusRequester = remember { FocusRequester() }
val brush = remember { Brush.horizontalGradient(listOf(Color.Black, Color.Transparent)) }
val focusManager = LocalFocusManager.current
if (drawerState.currentValue == DrawerValue.Open) {
BackHandler(
onBack = {
drawerState.setValue(DrawerValue.Closed)
focusManager.moveFocus(FocusDirection.Right)
}
)
}
ModalNavigationDrawer(
modifier =
Modifier.focusRequester(focusRequester).focusProperties {
enter = { if (focusRequester.restoreFocusedChild()) Cancel else Default }
},
drawerState = drawerState,
scrimBrush = brush,
drawerContent = {
Box(
Modifier.fillMaxHeight()
.background(brush)
.padding(
top = Dimens.screenVerticalMargin,
bottom = Dimens.screenVerticalMargin,
start = Dimens.tvDrawerHorizontalPadding,
end = Dimens.tvDrawerHorizontalPadding,
)
.selectableGroup()
) {
val animatedPadding =
animateDpAsState(
if (hasFocus) Dimens.tvDrawerHeaderWithFocusStartPadding
else Dimens.tvDrawerHeaderStartPadding
)
NavigationDrawerTvHeader(
modifier =
Modifier.align(Alignment.TopStart).padding(start = animatedPadding.value),
isExpanded = hasFocus,
daysLeftUntilExpiry = daysLeftUntilExpiry,
deviceName = deviceName,
)
DrawerItemTv(
modifier =
Modifier.align(Alignment.CenterStart).onFocusChanged {
focusRequester.saveFocusedChild()
},
icon = Icons.Default.AccountCircle,
text = stringResource(R.string.settings_account),
onClick = onAccountClick,
)
DrawerItemTv(
modifier =
Modifier.align(Alignment.BottomStart).onFocusChanged {
focusRequester.saveFocusedChild()
},
icon = Icons.Default.Settings,
text = stringResource(R.string.settings),
onClick = onSettingsClick,
)
}
},
content = content,
)
}
@Composable
private fun NavigationDrawerScope.DrawerItemTv(
modifier: Modifier = Modifier,
icon: ImageVector,
text: String,
onClick: () -> Unit,
) {
NavigationDrawerItem(
modifier = modifier,
onClick = onClick,
selected = false,
leadingContent = {
Icon(
tint = MaterialTheme.colorScheme.onPrimary,
imageVector = icon,
contentDescription = null,
)
},
) {
Text(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.onPrimary,
text = text,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
@Composable
private fun NavigationDrawerTvHeader(
modifier: Modifier = Modifier,
isExpanded: Boolean,
daysLeftUntilExpiry: Long?,
deviceName: String?,
) {
Column(
modifier =
modifier.width(
if (isExpanded) NavigationDrawerItemDefaults.ExpandedDrawerItemWidth
else NavigationDrawerItemDefaults.CollapsedDrawerItemWidth
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(Dimens.mullvadLogoTextStartPadding),
) {
Icon(
modifier = Modifier.size(Dimens.mediumIconSize),
painter = painterResource(id = R.drawable.logo_icon),
contentDescription = null, // No meaningful user info or action.
tint = Color.Unspecified, // Logo should not be tinted
)
if (isExpanded) {
Icon(
modifier = Modifier.height(Dimens.mullvadLogoTextHeight),
painter = painterResource(id = R.drawable.logo_text),
contentDescription = null, // No meaningful user info or action.
tint = Color.Unspecified, // Logo should not be tinted
)
}
}
Spacer(Modifier.height(8.dp))
if (isExpanded) {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.top_bar_device_name, deviceName ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
overflow = TextOverflow.Clip,
)
Spacer(Modifier.height(4.dp))
Text(
text =
stringResource(
id = R.string.top_bar_time_left,
pluralStringResource(
id = R.plurals.days,
daysLeftUntilExpiry?.toInt() ?: 0,
daysLeftUntilExpiry ?: 0,
),
),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimary,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
}

View File

@ -0,0 +1,63 @@
package net.mullvad.mullvadvpn.lib.tv
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.theme.AppTheme
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.ui.component.AnimatedNotificationBanner
@Preview
@Composable
fun PreviewNotificationBannerTv() {
AppTheme {
NotificationBannerTv(
notification = InAppNotification.NewDevice("Sad Panda"),
isPlayBuild = true,
openAppListing = {},
onClickShowAccount = {},
onClickShowChangelog = {},
onClickDismissChangelog = {},
) {}
}
}
@Composable
fun NotificationBannerTv(
modifier: Modifier = Modifier,
notification: InAppNotification?,
isPlayBuild: Boolean,
openAppListing: () -> Unit,
onClickShowAccount: () -> Unit,
onClickShowChangelog: () -> Unit,
onClickDismissChangelog: () -> Unit,
onClickDismissNewDevice: () -> Unit,
) {
AnimatedNotificationBanner(
modifier = modifier,
notificationModifier =
Modifier.width(Dimens.connectionCardMaxWidth)
.padding(start = Dimens.mediumPadding, end = Dimens.mediumPadding)
.clip(
RoundedCornerShape(
bottomEnd = Dimens.mediumPadding,
bottomStart = Dimens.mediumPadding,
topStart = 0.dp,
topEnd = 0.dp,
)
),
notification = notification,
isPlayBuild = isPlayBuild,
openAppListing = openAppListing,
onClickShowAccount = onClickShowAccount,
onClickShowChangelog = onClickShowChangelog,
onClickDismissChangelog = onClickDismissChangelog,
onClickDismissNewDevice = onClickDismissNewDevice,
)
}

View File

@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.compose)
}
android {
namespace = "net.mullvad.mullvadvpn.lib.ui.component"
compileSdk = Versions.compileSdkVersion
buildToolsVersion = Versions.buildToolsVersion
defaultConfig { minSdk = Versions.minSdkVersion }
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = Versions.jvmTarget
allWarningsAsErrors = true
}
lint {
lintConfig = file("${rootProject.projectDir}/config/lint.xml")
abortOnError = true
warningsAsErrors = true
}
}
dependencies {
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.constrainlayout)
implementation(libs.kotlin.stdlib)
implementation(libs.compose.icons.extended)
implementation(libs.androidx.ktx)
implementation(projects.lib.resource)
implementation(projects.lib.shared)
implementation(projects.lib.theme)
implementation(projects.lib.model)
}

View File

@ -0,0 +1 @@
<manifest />

View File

@ -0,0 +1,208 @@
package net.mullvad.mullvadvpn.lib.ui.component
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.toUpperCase
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.StatusLevel
import net.mullvad.mullvadvpn.lib.theme.Dimens
import net.mullvad.mullvadvpn.lib.theme.color.warning
import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER
import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_ACTION
import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_TEXT_ACTION
@Composable
fun AnimatedNotificationBanner(
modifier: Modifier = Modifier,
notificationModifier: Modifier = Modifier,
notification: InAppNotification?,
isPlayBuild: Boolean,
openAppListing: () -> Unit,
onClickShowAccount: () -> Unit,
onClickShowChangelog: () -> Unit,
onClickDismissChangelog: () -> Unit,
onClickDismissNewDevice: () -> Unit,
) {
// Fix for animating to invisible state
val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true })
AnimatedVisibility(
modifier = modifier,
visible = notification != null,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
) {
val visibleNotification = notification ?: previous
if (visibleNotification != null)
Notification(
modifier = notificationModifier,
visibleNotification.toNotificationData(
isPlayBuild = isPlayBuild,
openAppListing,
onClickShowAccount,
onClickShowChangelog,
onClickDismissChangelog,
onClickDismissNewDevice,
),
)
}
}
@Composable
@Suppress("LongMethod")
private fun Notification(modifier: Modifier = Modifier, notificationBannerData: NotificationData) {
val (title, message, statusLevel, action) = notificationBannerData
ConstraintLayout(
modifier =
modifier
.background(color = MaterialTheme.colorScheme.surfaceContainer)
.padding(
start = Dimens.notificationBannerStartPadding,
end = Dimens.notificationBannerEndPadding,
top = Dimens.smallPadding,
bottom = Dimens.smallPadding,
)
.animateContentSize()
.testTag(NOTIFICATION_BANNER)
) {
val (status, textTitle, textMessage, actionIcon) = createRefs()
NotificationDot(
statusLevel,
Modifier.constrainAs(status) {
top.linkTo(textTitle.top)
start.linkTo(parent.start)
bottom.linkTo(textTitle.bottom)
},
)
Text(
text = title.toUpperCase(),
modifier =
Modifier.constrainAs(textTitle) {
top.linkTo(parent.top)
start.linkTo(status.end)
if (message != null) {
bottom.linkTo(textMessage.top)
} else {
bottom.linkTo(parent.bottom)
}
if (action != null) {
end.linkTo(actionIcon.start)
} else {
end.linkTo(parent.end)
}
width = Dimension.fillToConstraints
}
.padding(start = Dimens.smallPadding),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
message?.let { message ->
Text(
text = message.text,
modifier =
Modifier.constrainAs(textMessage) {
top.linkTo(textTitle.bottom)
start.linkTo(textTitle.start)
if (action != null) {
end.linkTo(actionIcon.start)
bottom.linkTo(parent.bottom)
} else {
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
}
width = Dimension.fillToConstraints
height = Dimension.wrapContent
}
.padding(start = Dimens.smallPadding, top = Dimens.tinyPadding)
.wrapContentWidth(Alignment.Start)
.let {
if (message is NotificationMessage.ClickableText) {
it.clickable(
onClickLabel = message.contentDescription,
role = Role.Button,
) {
message.onClick()
}
.testTag(NOTIFICATION_BANNER_TEXT_ACTION)
} else {
it
}
},
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelMedium,
)
}
action?.let {
NotificationAction(
it.icon,
onClick = it.onClick,
contentDescription = it.contentDescription,
modifier =
Modifier.constrainAs(actionIcon) {
top.linkTo(parent.top)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
},
)
}
}
}
@Composable
private fun NotificationDot(statusLevel: StatusLevel, modifier: Modifier) {
Box(
modifier =
modifier
.background(
color =
when (statusLevel) {
StatusLevel.Error -> MaterialTheme.colorScheme.error
StatusLevel.Warning -> MaterialTheme.colorScheme.warning
StatusLevel.Info -> MaterialTheme.colorScheme.tertiary
},
shape = CircleShape,
)
.size(Dimens.notificationStatusIconSize)
)
}
@Composable
private fun NotificationAction(
imageVector: ImageVector,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
IconButton(modifier = modifier.testTag(NOTIFICATION_BANNER_ACTION), onClick = onClick) {
Icon(
modifier = Modifier.padding(Dimens.notificationIconPadding),
imageVector = imageVector,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurface,
)
}
}

View File

@ -1,4 +1,4 @@
package net.mullvad.mullvadvpn.compose.component.notificationbanner
package net.mullvad.mullvadvpn.lib.ui.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.OpenInNew
@ -16,15 +16,12 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.core.text.HtmlCompat
import java.net.InetAddress
import net.mullvad.mullvadvpn.R
import net.mullvad.mullvadvpn.compose.extensions.getExpiryQuantityString
import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
import net.mullvad.mullvadvpn.lib.model.AuthFailedError
import net.mullvad.mullvadvpn.lib.model.ErrorState
import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
import net.mullvad.mullvadvpn.lib.model.InAppNotification
import net.mullvad.mullvadvpn.lib.model.ParameterGenerationError
import net.mullvad.mullvadvpn.repository.InAppNotification
import net.mullvad.mullvadvpn.ui.notification.StatusLevel
import net.mullvad.mullvadvpn.lib.model.StatusLevel
data class NotificationData(
val title: AnnotatedString,

View File

@ -1,4 +1,4 @@
package net.mullvad.mullvadvpn.compose.util
package net.mullvad.mullvadvpn.lib.ui.component
/*
* Code snippet taken from:

View File

@ -1,8 +1,7 @@
package net.mullvad.mullvadvpn.compose.extensions
package net.mullvad.mullvadvpn.lib.ui.component
import android.content.res.Resources
import java.time.Duration
import net.mullvad.mullvadvpn.R
private const val DAYS_IN_STANDARD_YEAR = 365

View File

@ -1,4 +1,4 @@
package net.mullvad.mullvadvpn.compose.extensions
package net.mullvad.mullvadvpn.lib.ui.component
import android.graphics.Typeface
import android.text.Spanned

View File

@ -0,0 +1,6 @@
package net.mullvad.mullvadvpn.lib.ui.component.test
// ConnectScreen - Notification banner
const val NOTIFICATION_BANNER = "notification_banner"
const val NOTIFICATION_BANNER_ACTION = "notification_banner_action"
const val NOTIFICATION_BANNER_TEXT_ACTION = "notification_banner_text_action"

View File

@ -27,7 +27,9 @@ include(
":lib:resource",
":lib:shared",
":lib:talpid",
":lib:theme"
":lib:theme",
":lib:tv",
":lib:ui:component",
)
include(
":test",