summaryrefslogtreecommitdiffhomepage
path: root/android/lib/tv
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2025-03-11 11:00:15 +0100
committerKalle Lindström <karl.lindstrom@mullvad.net>2025-03-19 09:33:07 +0100
commite2d5d4f1f444f1dfbee5077889a62731aec080c6 (patch)
tree94e8bfca4a946f931ae3ce6b22a8a50eec795b48 /android/lib/tv
parent793c39338a2fcd2bf188952061edaeaa925d614a (diff)
downloadmullvadvpn-e2d5d4f1f444f1dfbee5077889a62731aec080c6.tar.xz
mullvadvpn-e2d5d4f1f444f1dfbee5077889a62731aec080c6.zip
Improve TV connect screen UI
- Implements the navigation rail design for Android TV - Implements the TV notification banner design - Adds two new Gradle modules: * tv: contains the Android TV specific Compose components (e.g. the NavigationDrawerTV component) * ui/compose: contains Compose-specific code that is needed by both the app module and the tv module.
Diffstat (limited to 'android/lib/tv')
-rw-r--r--android/lib/tv/build.gradle.kts48
-rw-r--r--android/lib/tv/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt251
-rw-r--r--android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt63
4 files changed, 363 insertions, 0 deletions
diff --git a/android/lib/tv/build.gradle.kts b/android/lib/tv/build.gradle.kts
new file mode 100644
index 0000000000..ef6b922da6
--- /dev/null
+++ b/android/lib/tv/build.gradle.kts
@@ -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)
+}
diff --git a/android/lib/tv/src/main/AndroidManifest.xml b/android/lib/tv/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/android/lib/tv/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<manifest />
diff --git a/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt
new file mode 100644
index 0000000000..d8a373c2b2
--- /dev/null
+++ b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NavigationDrawerTv.kt
@@ -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,
+ )
+ }
+ }
+}
diff --git a/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt
new file mode 100644
index 0000000000..97d986c36a
--- /dev/null
+++ b/android/lib/tv/src/main/kotlin/net/mullvad/mullvadvpn/lib/tv/NotificationBannerTv.kt
@@ -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,
+ )
+}