Skip to content

Commit

Permalink
android: style exit node picker per Material UI and add disable button
Browse files Browse the repository at this point in the history
Updates #ENG-2911

Signed-off-by: Percy Wegmann <[email protected]>
  • Loading branch information
oxtoacart committed Mar 26, 2024
1 parent 3fea68e commit 8af5a13
Show file tree
Hide file tree
Showing 12 changed files with 377 additions and 211 deletions.
8 changes: 5 additions & 3 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import com.tailscale.ipn.ui.view.MainView
import com.tailscale.ipn.ui.view.MainViewNavigation
import com.tailscale.ipn.ui.view.ManagedByView
import com.tailscale.ipn.ui.view.MullvadExitNodePicker
import com.tailscale.ipn.ui.view.MullvadExitNodePickerList
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.RunExitNodeView
import com.tailscale.ipn.ui.view.Settings
Expand Down Expand Up @@ -96,23 +97,24 @@ class MainActivity : ComponentActivity() {
onNavigateHome = {
navController.popBackStack(route = "main", inclusive = false)
},
onNavigateBack = { navController.popBackStack() },
onNavigateToExitNodePicker = { navController.popBackStack() },
onNavigateToMullvad = { navController.navigate("mullvad") },
onNavigateToMullvadCountry = { navController.navigate("mullvad/$it") },
onNavigateToRunAsExitNode = { navController.navigate("runExitNode") })

composable("main") { MainView(navigation = mainViewNav) }
composable("settings") { Settings(settingsNav) }
navigation(startDestination = "list", route = "exitNodes") {
composable("list") { ExitNodePicker(exitNodePickerNav) }
composable("mullvad") { MullvadExitNodePickerList(exitNodePickerNav) }
composable(
"mullvad/{countryCode}",
arguments = listOf(navArgument("countryCode") { type = NavType.StringType })) {
MullvadExitNodePicker(
it.arguments!!.getString("countryCode")!!, exitNodePickerNav)
}
composable("runExitNode") {
RunExitNodeView(exitNodePickerNav)
}
composable("runExitNode") { RunExitNodeView(exitNodePickerNav) }
}
composable(
"peerDetails/{nodeId}",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn.ui.util

import androidx.compose.foundation.clickable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.semantics.Role

/**
* Similar to Modifier.clickable, but if enabled == false, this adds a 75% alpha to make disabled
* items appear grayed out.
*/
@Composable
fun Modifier.clickableOrGrayedOut(
enabled: Boolean = true,
onClickLabel: String? = null,
role: Role? = null,
onClick: () -> Unit
) =
if (enabled) {
clickable(onClickLabel = onClickLabel, role = role, onClick = onClick)
} else {
alpha(0.75f)
}
43 changes: 43 additions & 0 deletions android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause

package com.tailscale.ipn.ui.util

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp

object Lists {
@Composable
fun SectionDivider() {
Box(Modifier.size(0.dp, 24.dp))
}

@Composable
fun ItemDivider() {
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer)
}
}

/** Similar to items() but includes a horizontal divider between items. */
inline fun <T> LazyListScope.itemsWithDividers(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
crossinline contentType: (item: T) -> Any? = { _ -> null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
) =
items(
count = items.size,
key = if (key != null) { index: Int -> key(items[index]) } else null,
contentType = { index -> contentType(items[index]) }) {
if (it > 0 && it < items.size) {
Lists.ItemDivider()
}
itemContent(items[it])
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
package com.tailscale.ipn.ui.util

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.fillMaxWidth
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.tailscale.ipn.ui.view.TailscaleLogoView
import kotlinx.coroutines.flow.MutableStateFlow

object LoadingIndicator {
Expand All @@ -35,12 +38,12 @@ object LoadingIndicator {
content()
val isLoading = loading.collectAsState().value
if (isLoading) {
Box(Modifier.matchParentSize().background(Color.Gray.copy(alpha = 0.5f)))
Box(Modifier.clickable {}.matchParentSize().background(Color.Gray.copy(alpha = 0.5f)))

Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
TailscaleLogoView(true, Modifier.size(72.dp))
}
}
}
Expand Down
101 changes: 35 additions & 66 deletions android/src/main/java/com/tailscale/ipn/ui/view/ExitNodePicker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,29 @@ package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.util.clickableOrGrayedOut
import com.tailscale.ipn.ui.util.itemsWithDividers
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
import com.tailscale.ipn.ui.viewModel.selected

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand All @@ -43,16 +37,17 @@ fun ExitNodePicker(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) {
LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateHome) }) {
Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateBack) }) {
innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val tailnetExitNodes = model.tailnetExitNodes.collectAsState().value
val mullvadExitNodesByCountryCode = model.mullvadExitNodesByCountryCode.collectAsState().value
val mullvadExitNodeCount = model.mullvadExitNodeCount.collectAsState().value
val anyActive = model.anyActive.collectAsState()

LazyColumn(modifier = Modifier.padding(innerPadding)) {
item(key = "runExitNode") {
RunAsExitNodeItem(nav = nav, viewModel = model)
HorizontalDivider()
Lists.SectionDivider()
}

item(key = "none") {
Expand All @@ -65,48 +60,15 @@ fun ExitNodePicker(
))
}

item { ListHeading(stringResource(R.string.tailnet_exit_nodes)) }
item { Lists.SectionDivider() }

items(tailnetExitNodes.value, key = { it.id!! }) { node ->
ExitNodeItem(model, node, indent = 16.dp)
}

item { ListHeading(stringResource(R.string.mullvad_exit_nodes)) }
itemsWithDividers(tailnetExitNodes, key = { it.id!! }) { node -> ExitNodeItem(model, node) }

val sortedCountries =
mullvadExitNodes.value.entries.toList().sortedBy {
it.value.first().country.lowercase()
}
items(sortedCountries) { (countryCode, nodes) ->
val first = nodes.first()
item { Lists.SectionDivider() }

// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast
// to androidx.compose.runtime.RecomposeScopeImpl
// Wrapping it in a Box eliminates this. It appears to be some kind of
// interaction between the LazyList and the modifier.
Box {
ListItem(
modifier =
Modifier.padding(start = 16.dp).clickable {
if (nodes.size > 1) {
nav.onNavigateToMullvadCountry(countryCode)
} else {
model.setExitNode(first)
}
},
headlineContent = { Text("${countryCode.flag()} ${first.country}") },
trailingContent = {
val text = if (nodes.size == 1) first.city else "${nodes.size}"
val icon =
if (nodes.size > 1) Icons.AutoMirrored.Outlined.KeyboardArrowRight
else if (first.selected) Icons.Outlined.Check else null
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text)
Spacer(modifier = Modifier.width(8.dp))
icon?.let { Icon(it, contentDescription = stringResource(R.string.more)) }
}
})
if (mullvadExitNodeCount > 0) {
item(key = "mullvad") {
MullvadItem(nav, mullvadExitNodeCount, mullvadExitNodesByCountryCode.selected)
}
}

Expand All @@ -116,36 +78,43 @@ fun ExitNodePicker(
}
}

@Composable
fun ListHeading(label: String, indent: Dp = 0.dp) {
ListItem(
modifier = Modifier.padding(start = indent),
headlineContent = { Text(text = label, style = MaterialTheme.typography.titleMedium) })
}

@Composable
fun ExitNodeItem(
viewModel: ExitNodePickerViewModel,
node: ExitNodePickerViewModel.ExitNode,
indent: Dp = 0.dp
) {
Box {
// TODO: add disabled styling
ListItem(
modifier = Modifier.padding(start = indent).clickable { viewModel.setExitNode(node) },
modifier =
Modifier.clickableOrGrayedOut(enabled = node.online) { viewModel.setExitNode(node) },
headlineContent = { Text(node.city.ifEmpty { node.label }) },
supportingContent = { if (!node.online) Text(stringResource(R.string.offline)) },
trailingContent = {
Row {
if (node.selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.more))
} else if (!node.online) {
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.offline))
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
}
}
})
}
}

@Composable
fun MullvadItem(nav: ExitNodePickerNav, count: Int, selected: Boolean) {
Box {
ListItem(
modifier = Modifier.clickable { nav.onNavigateToMullvad() },
headlineContent = { Text(stringResource(R.string.mullvad_exit_nodes)) },
supportingContent = { Text("$count ${stringResource(R.string.nodes_available)}") },
trailingContent = {
if (selected) {
Icon(Icons.Outlined.Check, contentDescription = stringResource(R.string.selected))
}
})
}
}

@Composable
fun RunAsExitNodeItem(nav: ExitNodePickerNav, viewModel: ExitNodePickerViewModel) {
val isRunningExitNode = viewModel.isRunningExitNode.collectAsState().value
Expand Down
Loading

0 comments on commit 8af5a13

Please sign in to comment.