Skip to content

Commit 22fc75d

Browse files
committed
Merge branch 'improve-tv-ui-experience'
2 parents 793c393 + faabed7 commit 22fc75d

File tree

51 files changed

+1027
-345
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+1027
-345
lines changed

android/CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ Line wrap the file at 100 chars. Th
2929
### Changed
3030
- Disable Wireguard port setting when a obfuscation is selected since it is not used when an
3131
obfuscation is applied.
32+
- Adapt UI on Connect Screen for Android TV, including a navigation rail and redesigned in-app
33+
notification bar.
3234

3335
### Removed
3436
- Remove Google's resolvers from encrypted DNS proxy.

android/app/build.gradle.kts

+3
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,8 @@ dependencies {
372372
implementation(projects.lib.resource)
373373
implementation(projects.lib.shared)
374374
implementation(projects.lib.talpid)
375+
implementation(projects.lib.tv)
376+
implementation(projects.lib.ui.component)
375377
implementation(projects.tile)
376378
implementation(projects.lib.theme)
377379
implementation(projects.service)
@@ -388,6 +390,7 @@ dependencies {
388390
implementation(libs.androidx.lifecycle.runtime)
389391
implementation(libs.androidx.lifecycle.viewmodel)
390392
implementation(libs.androidx.lifecycle.runtime.compose)
393+
implementation(libs.androidx.tv)
391394
implementation(libs.arrow)
392395
implementation(libs.arrow.optics)
393396
implementation(libs.arrow.resilience)

android/app/src/androidTest/kotlin/net/mullvad/mullvadvpn/compose/screen/ConnectScreenTest.kt

+4-4
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,20 @@ import net.mullvad.mullvadvpn.compose.state.ConnectUiState
1919
import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR
2020
import net.mullvad.mullvadvpn.compose.test.CONNECT_BUTTON_TEST_TAG
2121
import net.mullvad.mullvadvpn.compose.test.CONNECT_CARD_HEADER_TEST_TAG
22-
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION
23-
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION
2422
import net.mullvad.mullvadvpn.compose.test.RECONNECT_BUTTON_TEST_TAG
2523
import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_BUTTON_TEST_TAG
2624
import net.mullvad.mullvadvpn.compose.test.TOP_BAR_ACCOUNT_BUTTON
2725
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
2826
import net.mullvad.mullvadvpn.lib.model.ErrorState
2927
import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
3028
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
29+
import net.mullvad.mullvadvpn.lib.model.InAppNotification
3130
import net.mullvad.mullvadvpn.lib.model.TransportProtocol
3231
import net.mullvad.mullvadvpn.lib.model.TunnelEndpoint
3332
import net.mullvad.mullvadvpn.lib.model.TunnelState
34-
import net.mullvad.mullvadvpn.repository.InAppNotification
35-
import net.mullvad.mullvadvpn.ui.VersionInfo
33+
import net.mullvad.mullvadvpn.lib.model.VersionInfo
34+
import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_ACTION
35+
import net.mullvad.mullvadvpn.lib.ui.component.test.NOTIFICATION_BANNER_TEXT_ACTION
3636
import org.junit.jupiter.api.AfterEach
3737
import org.junit.jupiter.api.BeforeEach
3838
import org.junit.jupiter.api.Test
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,25 @@
11
package net.mullvad.mullvadvpn.compose.component.notificationbanner
22

3-
import androidx.compose.animation.AnimatedVisibility
4-
import androidx.compose.animation.animateContentSize
5-
import androidx.compose.animation.slideInVertically
6-
import androidx.compose.animation.slideOutVertically
73
import androidx.compose.foundation.background
8-
import androidx.compose.foundation.clickable
9-
import androidx.compose.foundation.layout.Box
104
import androidx.compose.foundation.layout.Column
115
import androidx.compose.foundation.layout.Spacer
126
import androidx.compose.foundation.layout.fillMaxWidth
13-
import androidx.compose.foundation.layout.padding
147
import androidx.compose.foundation.layout.size
15-
import androidx.compose.foundation.layout.wrapContentWidth
16-
import androidx.compose.foundation.shape.CircleShape
17-
import androidx.compose.material3.Icon
18-
import androidx.compose.material3.IconButton
198
import androidx.compose.material3.MaterialTheme
20-
import androidx.compose.material3.Text
219
import androidx.compose.runtime.Composable
22-
import androidx.compose.ui.Alignment
2310
import androidx.compose.ui.Modifier
24-
import androidx.compose.ui.graphics.vector.ImageVector
25-
import androidx.compose.ui.platform.testTag
26-
import androidx.compose.ui.semantics.Role
27-
import androidx.compose.ui.text.style.TextOverflow
28-
import androidx.compose.ui.text.toUpperCase
2911
import androidx.compose.ui.tooling.preview.Preview
3012
import androidx.compose.ui.unit.dp
31-
import androidx.constraintlayout.compose.ConstraintLayout
32-
import androidx.constraintlayout.compose.Dimension
3313
import java.time.Duration
3414
import net.mullvad.mullvadvpn.compose.component.MullvadTopBar
35-
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER
36-
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_ACTION
37-
import net.mullvad.mullvadvpn.compose.test.NOTIFICATION_BANNER_TEXT_ACTION
38-
import net.mullvad.mullvadvpn.compose.util.rememberPrevious
15+
import net.mullvad.mullvadvpn.compose.util.isTv
3916
import net.mullvad.mullvadvpn.lib.model.ErrorState
4017
import net.mullvad.mullvadvpn.lib.model.ErrorStateCause
18+
import net.mullvad.mullvadvpn.lib.model.InAppNotification
19+
import net.mullvad.mullvadvpn.lib.model.VersionInfo
4120
import net.mullvad.mullvadvpn.lib.theme.AppTheme
42-
import net.mullvad.mullvadvpn.lib.theme.Dimens
43-
import net.mullvad.mullvadvpn.lib.theme.color.warning
44-
import net.mullvad.mullvadvpn.repository.InAppNotification
45-
import net.mullvad.mullvadvpn.ui.VersionInfo
46-
import net.mullvad.mullvadvpn.ui.notification.StatusLevel
21+
import net.mullvad.mullvadvpn.lib.tv.NotificationBannerTv
22+
import net.mullvad.mullvadvpn.lib.ui.component.AnimatedNotificationBanner
4723

4824
@Preview
4925
@Composable
@@ -52,18 +28,17 @@ private fun PreviewNotificationBanner() {
5228
Column(Modifier.background(color = MaterialTheme.colorScheme.surface)) {
5329
val bannerDataList =
5430
listOf(
55-
InAppNotification.UnsupportedVersion(
56-
versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false)
57-
),
58-
InAppNotification.AccountExpiry(expiry = Duration.ZERO),
59-
InAppNotification.TunnelStateBlocked,
60-
InAppNotification.NewDevice("Courageous Turtle"),
61-
InAppNotification.TunnelStateError(
62-
error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true)
63-
),
64-
InAppNotification.NewVersionChangelog,
65-
)
66-
.map { it.toNotificationData(false, {}, {}, {}, {}, {}) }
31+
InAppNotification.UnsupportedVersion(
32+
versionInfo = VersionInfo(currentVersion = "1.0", isSupported = false)
33+
),
34+
InAppNotification.AccountExpiry(expiry = Duration.ZERO),
35+
InAppNotification.TunnelStateBlocked,
36+
InAppNotification.NewDevice("Courageous Turtle"),
37+
InAppNotification.TunnelStateError(
38+
error = ErrorState(ErrorStateCause.FirewallPolicyError.Generic, true)
39+
),
40+
InAppNotification.NewVersionChangelog,
41+
)
6742

6843
bannerDataList.forEach {
6944
MullvadTopBar(
@@ -72,7 +47,15 @@ private fun PreviewNotificationBanner() {
7247
onAccountClicked = {},
7348
iconTintColor = MaterialTheme.colorScheme.primary,
7449
)
75-
Notification(it)
50+
NotificationBanner(
51+
notification = it,
52+
isPlayBuild = false,
53+
openAppListing = {},
54+
onClickShowAccount = {},
55+
onClickShowChangelog = {},
56+
onClickDismissChangelog = {},
57+
onClickDismissNewDevice = {},
58+
)
7659
Spacer(modifier = Modifier.size(16.dp))
7760
}
7861
}
@@ -90,163 +73,28 @@ fun NotificationBanner(
9073
onClickDismissChangelog: () -> Unit,
9174
onClickDismissNewDevice: () -> Unit,
9275
) {
93-
// Fix for animating to invisible state
94-
val previous = rememberPrevious(current = notification, shouldUpdate = { _, _ -> true })
95-
AnimatedVisibility(
96-
visible = notification != null,
97-
enter = slideInVertically(initialOffsetY = { -it }),
98-
exit = slideOutVertically(targetOffsetY = { -it }),
99-
modifier = modifier,
100-
) {
101-
val visibleNotification = notification ?: previous
102-
if (visibleNotification != null)
103-
Notification(
104-
visibleNotification.toNotificationData(
105-
isPlayBuild = isPlayBuild,
106-
openAppListing,
107-
onClickShowAccount,
108-
onClickShowChangelog,
109-
onClickDismissChangelog,
110-
onClickDismissNewDevice,
111-
)
112-
)
113-
}
114-
}
115-
116-
@Composable
117-
@Suppress("LongMethod")
118-
private fun Notification(notificationBannerData: NotificationData) {
119-
val (title, message, statusLevel, action) = notificationBannerData
120-
ConstraintLayout(
121-
modifier =
122-
Modifier.fillMaxWidth()
123-
.background(color = MaterialTheme.colorScheme.surfaceContainer)
124-
.padding(
125-
start = Dimens.notificationBannerStartPadding,
126-
end = Dimens.notificationBannerEndPadding,
127-
top = Dimens.smallPadding,
128-
bottom = Dimens.smallPadding,
129-
)
130-
.animateContentSize()
131-
.testTag(NOTIFICATION_BANNER)
132-
) {
133-
val (status, textTitle, textMessage, actionIcon) = createRefs()
134-
NotificationDot(
135-
statusLevel,
136-
Modifier.constrainAs(status) {
137-
top.linkTo(textTitle.top)
138-
start.linkTo(parent.start)
139-
bottom.linkTo(textTitle.bottom)
140-
},
141-
)
142-
Text(
143-
text = title.toUpperCase(),
144-
modifier =
145-
Modifier.constrainAs(textTitle) {
146-
top.linkTo(parent.top)
147-
start.linkTo(status.end)
148-
if (message != null) {
149-
bottom.linkTo(textMessage.top)
150-
} else {
151-
bottom.linkTo(parent.bottom)
152-
}
153-
if (action != null) {
154-
end.linkTo(actionIcon.start)
155-
} else {
156-
end.linkTo(parent.end)
157-
}
158-
width = Dimension.fillToConstraints
159-
}
160-
.padding(start = Dimens.smallPadding),
161-
style = MaterialTheme.typography.bodySmall,
162-
color = MaterialTheme.colorScheme.onSurface,
163-
maxLines = 1,
164-
overflow = TextOverflow.Ellipsis,
76+
if (isTv()) {
77+
NotificationBannerTv(
78+
modifier = modifier,
79+
notification = notification,
80+
isPlayBuild = isPlayBuild,
81+
openAppListing = openAppListing,
82+
onClickShowAccount = onClickShowAccount,
83+
onClickShowChangelog = onClickShowChangelog,
84+
onClickDismissChangelog = onClickDismissChangelog,
85+
onClickDismissNewDevice = onClickDismissNewDevice,
16586
)
166-
message?.let { message ->
167-
Text(
168-
text = message.text,
169-
modifier =
170-
Modifier.constrainAs(textMessage) {
171-
top.linkTo(textTitle.bottom)
172-
start.linkTo(textTitle.start)
173-
if (action != null) {
174-
end.linkTo(actionIcon.start)
175-
bottom.linkTo(parent.bottom)
176-
} else {
177-
end.linkTo(parent.end)
178-
bottom.linkTo(parent.bottom)
179-
}
180-
width = Dimension.fillToConstraints
181-
height = Dimension.wrapContent
182-
}
183-
.padding(start = Dimens.smallPadding, top = Dimens.tinyPadding)
184-
.wrapContentWidth(Alignment.Start)
185-
.let {
186-
if (message is NotificationMessage.ClickableText) {
187-
it.clickable(
188-
onClickLabel = message.contentDescription,
189-
role = Role.Button,
190-
) {
191-
message.onClick()
192-
}
193-
.testTag(NOTIFICATION_BANNER_TEXT_ACTION)
194-
} else {
195-
it
196-
}
197-
},
198-
color = MaterialTheme.colorScheme.onSurfaceVariant,
199-
style = MaterialTheme.typography.labelMedium,
200-
)
201-
}
202-
action?.let {
203-
NotificationAction(
204-
it.icon,
205-
onClick = it.onClick,
206-
contentDescription = it.contentDescription,
207-
modifier =
208-
Modifier.constrainAs(actionIcon) {
209-
top.linkTo(parent.top)
210-
end.linkTo(parent.end)
211-
bottom.linkTo(parent.bottom)
212-
},
213-
)
214-
}
215-
}
216-
}
217-
218-
@Composable
219-
private fun NotificationDot(statusLevel: StatusLevel, modifier: Modifier) {
220-
Box(
221-
modifier =
222-
modifier
223-
.background(
224-
color =
225-
when (statusLevel) {
226-
StatusLevel.Error -> MaterialTheme.colorScheme.error
227-
StatusLevel.Warning -> MaterialTheme.colorScheme.warning
228-
StatusLevel.Info -> MaterialTheme.colorScheme.tertiary
229-
},
230-
shape = CircleShape,
231-
)
232-
.size(Dimens.notificationStatusIconSize)
233-
)
234-
}
235-
236-
@Composable
237-
private fun NotificationAction(
238-
imageVector: ImageVector,
239-
contentDescription: String?,
240-
onClick: () -> Unit,
241-
modifier: Modifier = Modifier,
242-
) {
243-
244-
IconButton(modifier = modifier.testTag(NOTIFICATION_BANNER_ACTION), onClick = onClick) {
245-
Icon(
246-
modifier = Modifier.padding(Dimens.notificationIconPadding),
247-
imageVector = imageVector,
248-
contentDescription = contentDescription,
249-
tint = MaterialTheme.colorScheme.onSurface,
87+
} else {
88+
AnimatedNotificationBanner(
89+
modifier = modifier,
90+
notificationModifier = Modifier.fillMaxWidth(),
91+
notification = notification,
92+
isPlayBuild = isPlayBuild,
93+
openAppListing = openAppListing,
94+
onClickShowAccount = onClickShowAccount,
95+
onClickShowChangelog = onClickShowChangelog,
96+
onClickDismissChangelog = onClickDismissChangelog,
97+
onClickDismissNewDevice = onClickDismissNewDevice,
25098
)
25199
}
252100
}

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/RemoveDeviceConfirmationDialog.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import com.ramcosta.composedestinations.result.ResultBackNavigator
1515
import com.ramcosta.composedestinations.spec.DestinationStyle
1616
import net.mullvad.mullvadvpn.R
1717
import net.mullvad.mullvadvpn.compose.component.textResource
18-
import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
1918
import net.mullvad.mullvadvpn.compose.preview.DevicePreviewParameterProvider
2019
import net.mullvad.mullvadvpn.lib.model.Device
2120
import net.mullvad.mullvadvpn.lib.model.DeviceId
2221
import net.mullvad.mullvadvpn.lib.theme.AppTheme
22+
import net.mullvad.mullvadvpn.lib.ui.component.toAnnotatedString
2323

2424
@Preview
2525
@Composable

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/dialog/info/InfoDialog.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import androidx.core.text.HtmlCompat
2424
import net.mullvad.mullvadvpn.R
2525
import net.mullvad.mullvadvpn.compose.button.PrimaryButton
2626
import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar
27-
import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString
2827
import net.mullvad.mullvadvpn.lib.theme.AppTheme
2928
import net.mullvad.mullvadvpn.lib.theme.Dimens
3029
import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar
30+
import net.mullvad.mullvadvpn.lib.ui.component.toAnnotatedString
3131

3232
@Preview
3333
@Composable

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/AppInfoUiStatePreviewParameterProvider.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package net.mullvad.mullvadvpn.compose.preview
22

33
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
4-
import net.mullvad.mullvadvpn.ui.VersionInfo
4+
import net.mullvad.mullvadvpn.lib.model.VersionInfo
55
import net.mullvad.mullvadvpn.viewmodel.AppInfoUiState
66

77
class AppInfoUiStatePreviewParameterProvider : PreviewParameterProvider<AppInfoUiState> {

android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/preview/ConnectUiStatePreviewParameterProvider.kt

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import java.net.InetAddress
55
import net.mullvad.mullvadvpn.compose.state.ConnectUiState
66
import net.mullvad.mullvadvpn.lib.model.ActionAfterDisconnect
77
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
8+
import net.mullvad.mullvadvpn.lib.model.InAppNotification
89

910
class ConnectUiStatePreviewParameterProvider : PreviewParameterProvider<ConnectUiState> {
1011
override val values = sequenceOf(ConnectUiState.INITIAL) + generateOtherStates()
@@ -29,7 +30,7 @@ private fun generateOtherStates(): Sequence<ConnectUiState> =
2930
),
3031
TunnelStatePreviewData.generateErrorState(isBlocking = true),
3132
)
32-
.map { state ->
33+
.mapIndexed { index, state ->
3334
ConnectUiState(
3435
location =
3536
GeoIpLocation(
@@ -45,7 +46,8 @@ private fun generateOtherStates(): Sequence<ConnectUiState> =
4546
selectedRelayItemTitle = "Relay Title",
4647
tunnelState = state,
4748
showLocation = true,
48-
inAppNotification = null,
49+
inAppNotification =
50+
if (index == 0) InAppNotification.NewDevice("Test Device") else null,
4951
deviceName = "Cool Beans",
5052
daysLeftUntilExpiry = 42,
5153
isPlayBuild = true,

0 commit comments

Comments
 (0)