android/ui: address preliminary design feedback

Updates tailscale/corp#18202

Adds back navigation to all of the headers.
Corrects all padding and some colours
Adds separators to the device list
Adds the Compat theme so we don't have the black top and bottom bars.
Removes all of the chevrons.
Other minor tweaks

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/237/head
Jonathan Nobels 3 months ago committed by GitHub
parent 5454b34dd1
commit e953b19189
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -29,6 +29,7 @@
android:allowBackup="false" android:allowBackup="false"
android:banner="@drawable/tv_banner" android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:theme="@style/Theme.AppCompat"
android:label="Tailscale" android:label="Tailscale"
android:roundIcon="@mipmap/ic_launcher_round"> android:roundIcon="@mipmap/ic_launcher_round">
<activity <activity

@ -28,6 +28,7 @@ import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.theme.AppTheme import com.tailscale.ipn.ui.theme.AppTheme
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import com.tailscale.ipn.ui.view.AboutView import com.tailscale.ipn.ui.view.AboutView
import com.tailscale.ipn.ui.view.BackNavigation
import com.tailscale.ipn.ui.view.BugReportView import com.tailscale.ipn.ui.view.BugReportView
import com.tailscale.ipn.ui.view.ExitNodePicker import com.tailscale.ipn.ui.view.ExitNodePicker
import com.tailscale.ipn.ui.view.MDMSettingsDebugView import com.tailscale.ipn.ui.view.MDMSettingsDebugView
@ -80,8 +81,11 @@ class MainActivity : ComponentActivity() {
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
onBackPressed = { navController.popBackStack() },
) )
val backNav = BackNavigation(onBack = { navController.popBackStack() })
val exitNodePickerNav = val exitNodePickerNav =
ExitNodePickerNav( ExitNodePickerNav(
onNavigateHome = { onNavigateHome = {
@ -103,13 +107,13 @@ class MainActivity : ComponentActivity() {
composable( composable(
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) { arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(it.arguments?.getString("nodeId") ?: "") PeerDetails(nav = backNav, it.arguments?.getString("nodeId") ?: "")
} }
composable("bugReport") { BugReportView() } composable("bugReport") { BugReportView(nav = backNav) }
composable("about") { AboutView() } composable("about") { AboutView(nav = backNav) }
composable("mdmSettings") { MDMSettingsDebugView() } composable("mdmSettings") { MDMSettingsDebugView(nav = backNav) }
composable("managedBy") { ManagedByView() } composable("managedBy") { ManagedByView(nav = backNav) }
composable("userSwitcher") { UserSwitcherView() } composable("userSwitcher") { UserSwitcherView(nav = backNav) }
} }
} }
} }

@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -32,13 +31,13 @@ import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
@Composable @Composable
fun AboutView() { fun AboutView(nav: BackNavigation) {
Scaffold { _ -> Scaffold(topBar = { Header(R.string.about_view_title, onBack = nav.onBack) }) { innerPadding ->
Column( Column(
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().fillMaxHeight().safeContentPadding()) { modifier = Modifier.fillMaxWidth().fillMaxHeight().padding(innerPadding)) {
Image( Image(
modifier = modifier =
Modifier.width(100.dp) Modifier.width(100.dp)

@ -24,50 +24,53 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.theme.ts_color_light_blue
import com.tailscale.ipn.ui.util.defaultPaddingModifier import com.tailscale.ipn.ui.util.defaultPaddingModifier
import com.tailscale.ipn.ui.util.settingsRowModifier import com.tailscale.ipn.ui.util.settingsRowModifier
import com.tailscale.ipn.ui.viewModel.BugReportViewModel import com.tailscale.ipn.ui.viewModel.BugReportViewModel
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@Composable @Composable
fun BugReportView(model: BugReportViewModel = viewModel()) { fun BugReportView(nav: BackNavigation, model: BugReportViewModel = viewModel()) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Scaffold(topBar = { Header(R.string.bug_report_title) }) { innerPadding -> Scaffold(topBar = { Header(R.string.bug_report_title, onBack = nav.onBack) }) { innerPadding ->
Column(modifier = Modifier.padding(innerPadding).padding(8.dp).fillMaxWidth().fillMaxHeight()) { Column(
ClickableText( modifier = Modifier.padding(innerPadding).padding(24.dp).fillMaxWidth().fillMaxHeight()) {
text = contactText(), ClickableText(
modifier = Modifier.fillMaxWidth(), text = contactText(),
style = MaterialTheme.typography.bodyMedium, modifier = Modifier.fillMaxWidth(),
onClick = { handler.openUri(Links.SUPPORT_URL) }) style = MaterialTheme.typography.bodyMedium,
onClick = { handler.openUri(Links.SUPPORT_URL) })
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
ReportIdRow(bugReportIdFlow = model.bugReportID) ReportIdRow(bugReportIdFlow = model.bugReportID)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = stringResource(id = R.string.bug_report_id_desc), text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary, color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
} }
} }
} }
@ -87,7 +90,8 @@ fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
Text( Text(
text = bugReportId.value, text = bugReportId.value,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
maxLines = 1, fontFamily = FontFamily.Monospace,
maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = defaultPaddingModifier()) modifier = defaultPaddingModifier())
} }
@ -105,9 +109,10 @@ fun contactText(): AnnotatedString {
} }
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(style = SpanStyle(color = Color.Blue)) { withStyle(
append(stringResource(id = R.string.bug_report_instructions_linktext)) style = SpanStyle(color = ts_color_light_blue, textDecoration = TextDecoration.Underline)) {
} append(stringResource(id = R.string.bug_report_instructions_linktext))
}
pop() pop()
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) { withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {

@ -43,7 +43,8 @@ fun ExitNodePicker(
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav)) model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
) { ) {
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
Scaffold(topBar = { Header(R.string.choose_exit_node) }) { innerPadding -> Scaffold(topBar = { Header(R.string.choose_exit_node, onBack = nav.onNavigateHome) }) {
innerPadding ->
val tailnetExitNodes = model.tailnetExitNodes.collectAsState() val tailnetExitNodes = model.tailnetExitNodes.collectAsState()
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState() val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
val anyActive = model.anyActive.collectAsState() val anyActive = model.anyActive.collectAsState()

@ -31,8 +31,9 @@ import com.tailscale.ipn.ui.viewModel.IpnViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MDMSettingsDebugView(model: IpnViewModel = viewModel()) { fun MDMSettingsDebugView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Scaffold(topBar = { Header(R.string.current_mdm_settings) }) { innerPadding -> Scaffold(topBar = { Header(R.string.current_mdm_settings, onBack = nav.onBack) }) { innerPadding
->
val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value val mdmSettings = IpnViewModel.mdmSettings.collectAsState().value
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(enumValues<BooleanSetting>()) { booleanSetting -> items(enumValues<BooleanSetting>()) { booleanSetting ->

@ -10,21 +10,23 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
@ -32,7 +34,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -68,49 +69,61 @@ data class MainViewNavigation(
@Composable @Composable
fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) { fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewModel()) {
Scaffold { _ -> Scaffold(contentWindowInsets = WindowInsets.Companion.statusBars) { paddingInsets ->
Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.Center) { Column(
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState) modifier = Modifier.fillMaxWidth().padding(paddingInsets),
val user = viewModel.loggedInUser.collectAsState(initial = null) verticalArrangement = Arrangement.Center) {
val state = viewModel.ipnState.collectAsState(initial = Ipn.State.NoState)
val user = viewModel.loggedInUser.collectAsState(initial = null)
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), modifier =
verticalAlignment = Alignment.CenterVertically) { Modifier.fillMaxWidth()
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) .background(MaterialTheme.colorScheme.secondaryContainer)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) { .padding(horizontal = 8.dp)
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) .padding(top = 10.dp),
Spacer(Modifier.size(3.dp)) verticalAlignment = Alignment.CenterVertically) {
} val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
if (state.value != Ipn.State.NeedsLogin && state.value != Ipn.State.NoState) {
TintedSwitch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
Spacer(Modifier.size(3.dp))
}
StateDisplay(viewModel.stateRes, viewModel.userName) StateDisplay(viewModel.stateRes, viewModel.userName)
Box( Box(
modifier = Modifier.weight(1f).clickable { navigation.onNavigateToSettings() }, modifier = Modifier.weight(1f).clickable { navigation.onNavigateToSettings() },
contentAlignment = Alignment.CenterEnd) { contentAlignment = Alignment.CenterEnd) {
when (user.value) { when (user.value) {
null -> SettingsButton(user.value) { navigation.onNavigateToSettings() } null -> SettingsButton(user.value) { navigation.onNavigateToSettings() }
else -> Avatar(profile = user.value, size = 36) else -> Avatar(profile = user.value, size = 36)
} }
} }
} }
when (state.value) { when (state.value) {
Ipn.State.Running -> { Ipn.State.Running -> {
val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "") val selfPeerId = viewModel.selfPeerId.collectAsState(initial = "")
ExitNodeStatus(navAction = navigation.onNavigateToExitNodes, viewModel = viewModel) Row(
PeerList( modifier =
searchTerm = viewModel.searchTerm, Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
state = viewModel.ipnState, .padding(top = 10.dp, bottom = 20.dp)) {
peers = viewModel.peers, ExitNodeStatus(
selfPeer = selfPeerId.value, navAction = navigation.onNavigateToExitNodes, viewModel = viewModel)
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, }
onSearch = { viewModel.searchPeers(it) }) PeerList(
searchTerm = viewModel.searchTerm,
state = viewModel.ipnState,
peers = viewModel.peers,
selfPeer = selfPeerId.value,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
}
Ipn.State.Starting -> StartingView()
else -> ConnectView(user.value, { viewModel.toggleVpn() }, { viewModel.login {} })
}
} }
Ipn.State.Starting -> StartingView()
else -> ConnectView(user.value, { viewModel.toggleVpn() }, { viewModel.login {} })
}
}
} }
} }
@ -135,16 +148,17 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
Modifier.clickable { navAction() } Modifier.clickable { navAction() }
.padding(horizontal = 8.dp) .padding(horizontal = 8.dp)
.clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp)) .clip(shape = RoundedCornerShape(10.dp, 10.dp, 10.dp, 10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer) .background(MaterialTheme.colorScheme.background)
.fillMaxWidth()) { .fillMaxWidth()) {
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(vertical = 15.dp, horizontal = 18.dp)) {
Text( Text(
text = stringResource(id = R.string.exit_node), text = stringResource(id = R.string.exit_node),
style = MaterialTheme.typography.titleMedium) color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.titleSmall)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
text = exitNode ?: stringResource(id = R.string.none), text = exitNode ?: stringResource(id = R.string.none),
style = MaterialTheme.typography.bodyMedium) style = MaterialTheme.typography.bodyLarge)
Icon( Icon(
Icons.Outlined.ArrowDropDown, Icons.Outlined.ArrowDropDown,
null, null,
@ -208,62 +222,64 @@ fun StartingView() {
fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) { fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAction: () -> Unit) {
Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.Center, modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier =
Modifier.background(MaterialTheme.colorScheme.secondaryContainer)
.padding(8.dp)
.fillMaxWidth(0.7f)
.fillMaxHeight(),
verticalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { modifier =
if (user != null && !user.isEmpty()) { Modifier.background(MaterialTheme.colorScheme.secondaryContainer).fillMaxWidth()) {
Icon( Column(
painter = painterResource(id = R.drawable.power), modifier = Modifier.padding(8.dp).fillMaxWidth(0.7f).fillMaxHeight(),
contentDescription = null, verticalArrangement =
modifier = Modifier.size(48.dp), Arrangement.spacedBy(8.dp, alignment = Alignment.CenterVertically),
tint = MaterialTheme.colorScheme.secondary) horizontalAlignment = Alignment.CenterHorizontally,
Text( ) {
text = stringResource(id = R.string.not_connected), if (user != null && !user.isEmpty()) {
fontSize = MaterialTheme.typography.titleMedium.fontSize, Icon(
fontWeight = FontWeight.SemiBold, painter = painterResource(id = R.drawable.power),
color = MaterialTheme.colorScheme.primary, contentDescription = null,
textAlign = TextAlign.Center, modifier = Modifier.size(48.dp),
fontFamily = MaterialTheme.typography.titleMedium.fontFamily) tint = MaterialTheme.colorScheme.secondary)
val tailnetName = user.NetworkProfile?.DomainName ?: "" Text(
Text( text = stringResource(id = R.string.not_connected),
stringResource(id = R.string.connect_to_tailnet, tailnetName), fontSize = MaterialTheme.typography.titleMedium.fontSize,
fontSize = MaterialTheme.typography.titleMedium.fontSize, fontWeight = FontWeight.SemiBold,
fontWeight = FontWeight.Normal, color = MaterialTheme.colorScheme.primary,
color = MaterialTheme.colorScheme.secondary, textAlign = TextAlign.Center,
textAlign = TextAlign.Center, fontFamily = MaterialTheme.typography.titleMedium.fontFamily)
) val tailnetName = user.NetworkProfile?.DomainName ?: ""
Spacer(modifier = Modifier.size(1.dp)) Text(
PrimaryActionButton(onClick = connectAction) { stringResource(id = R.string.connect_to_tailnet, tailnetName),
Text( fontSize = MaterialTheme.typography.titleMedium.fontSize,
text = stringResource(id = R.string.connect), fontWeight = FontWeight.Normal,
fontSize = MaterialTheme.typography.titleMedium.fontSize) color = MaterialTheme.colorScheme.secondary,
} textAlign = TextAlign.Center,
} else { )
TailscaleLogoView(Modifier.size(50.dp)) Spacer(modifier = Modifier.size(1.dp))
Spacer(modifier = Modifier.size(1.dp)) PrimaryActionButton(onClick = connectAction) {
Text( Text(
text = stringResource(id = R.string.welcome_to_tailscale), text = stringResource(id = R.string.connect),
style = MaterialTheme.typography.titleMedium, fontSize = MaterialTheme.typography.titleMedium.fontSize)
color = MaterialTheme.colorScheme.primary, }
textAlign = TextAlign.Center) } else {
Text( TailscaleLogoView(Modifier.size(50.dp))
stringResource(R.string.login_to_join_your_tailnet), Spacer(modifier = Modifier.size(1.dp))
style = MaterialTheme.typography.titleSmall, Text(
color = MaterialTheme.colorScheme.secondary, text = stringResource(id = R.string.welcome_to_tailscale),
textAlign = TextAlign.Center) style = MaterialTheme.typography.titleMedium,
Spacer(modifier = Modifier.size(1.dp)) color = MaterialTheme.colorScheme.primary,
PrimaryActionButton(onClick = loginAction) { textAlign = TextAlign.Center)
Text( Text(
text = stringResource(id = R.string.log_in), stringResource(R.string.login_to_join_your_tailnet),
fontSize = MaterialTheme.typography.titleMedium.fontSize) style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.secondary,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.size(1.dp))
PrimaryActionButton(onClick = loginAction) {
Text(
text = stringResource(id = R.string.log_in),
fontSize = MaterialTheme.typography.titleMedium.fontSize)
}
}
}
} }
}
}
} }
} }
@ -308,9 +324,11 @@ fun PeerList(
trailingIcon = { trailingIcon = {
if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton() if (searchTermStr.isNotEmpty()) ClearButton({ onSearch("") }) else CloseButton()
}, },
tonalElevation = 2.dp, tonalElevation = 0.dp,
shadowElevation = 2.dp, shadowElevation = 0.dp,
colors = SearchBarDefaults.colors(), colors =
SearchBarDefaults.colors(
containerColor = Color.Transparent, dividerColor = Color.Transparent),
modifier = Modifier.fillMaxWidth()) { modifier = Modifier.fillMaxWidth()) {
LazyColumn( LazyColumn(
modifier = modifier =
@ -323,7 +341,8 @@ fun PeerList(
Text( Text(
text = text =
peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user),
style = MaterialTheme.typography.titleLarge) style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold)
}) })
} }
peerSet.peers.forEach { peer -> peerSet.peers.forEach { peer ->
@ -344,19 +363,20 @@ fun PeerList(
} }
Box( Box(
modifier = modifier =
Modifier.size(8.dp) Modifier.size(10.dp)
.background( .background(
color = color, shape = RoundedCornerShape(percent = 50))) {} color = color, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(6.dp))
Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium) Text(text = peer.ComputedName, style = MaterialTheme.typography.titleMedium)
} }
}, },
supportingContent = { supportingContent = {
Text( Text(
text = peer.Addresses?.first()?.split("/")?.first() ?: "", text = peer.Addresses?.first()?.split("/")?.first() ?: "",
style = MaterialTheme.typography.bodyMedium) style = MaterialTheme.typography.bodyMedium,
}, color = MaterialTheme.colorScheme.secondary)
trailingContent = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) }) })
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer)
} }
} }
} }

@ -7,8 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -22,8 +21,8 @@ import com.tailscale.ipn.mdm.StringSetting
import com.tailscale.ipn.ui.viewModel.IpnViewModel import com.tailscale.ipn.ui.viewModel.IpnViewModel
@Composable @Composable
fun ManagedByView(model: IpnViewModel = viewModel()) { fun ManagedByView(nav: BackNavigation, model: IpnViewModel = viewModel()) {
Surface(color = MaterialTheme.colorScheme.surface) { Scaffold(topBar = { Header(R.string.managed_by, onBack = nav.onBack) }) { innerPadding ->
Column( Column(
verticalArrangement = verticalArrangement =
Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically), Arrangement.spacedBy(space = 20.dp, alignment = Alignment.CenterVertically),

@ -35,52 +35,55 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModelFactory
@Composable @Composable
fun PeerDetails( fun PeerDetails(
nav: BackNavigation,
nodeId: String, nodeId: String,
model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId)) model: PeerDetailsViewModel = viewModel(factory = PeerDetailsViewModelFactory(nodeId))
) { ) {
Scaffold( Scaffold(topBar = { Header(title = R.string.peer_details, onBack = nav.onBack) }) { innerPadding
topBar = { ->
Column( Column(
modifier = Modifier.fillMaxWidth().padding(8.dp), modifier =
) { Modifier.fillMaxWidth()
Text( .padding(innerPadding)
text = model.nodeName, .padding(horizontal = 16.dp)
style = MaterialTheme.typography.titleLarge, .padding(top = 22.dp),
color = MaterialTheme.colorScheme.primary) ) {
Row(verticalAlignment = Alignment.CenterVertically) { Text(
Box( text = model.nodeName,
modifier = style = MaterialTheme.typography.titleLarge,
Modifier.size(8.dp) color = MaterialTheme.colorScheme.primary)
.background( Row(verticalAlignment = Alignment.CenterVertically) {
color = model.connectedColor, Box(
shape = RoundedCornerShape(percent = 50))) {} modifier =
Spacer(modifier = Modifier.size(8.dp)) Modifier.size(8.dp)
Text( .background(
text = stringResource(id = model.connectedStrRes), color = model.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
style = MaterialTheme.typography.bodyMedium, Spacer(modifier = Modifier.size(8.dp))
color = MaterialTheme.colorScheme.primary) Text(
} text = stringResource(id = model.connectedStrRes),
} style = MaterialTheme.typography.bodyMedium,
}) { innerPadding -> color = MaterialTheme.colorScheme.primary)
Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) { }
Text( Column(modifier = Modifier.fillMaxHeight()) {
text = stringResource(id = R.string.addresses_section), Text(
style = MaterialTheme.typography.titleMedium, text = stringResource(id = R.string.addresses_section),
color = MaterialTheme.colorScheme.primary) style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary)
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
model.addresses.forEach { AddressRow(address = it.address, type = it.typeString) } model.addresses.forEach { AddressRow(address = it.address, type = it.typeString) }
} }
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
model.info.forEach { model.info.forEach {
ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString()) ValueRow(title = stringResource(id = it.titleRes), value = it.value.getString())
}
} }
} }
} }
}
}
} }
@Composable @Composable
@ -88,25 +91,29 @@ fun AddressRow(address: String, type: String) {
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
Row( Row(
verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
Modifier.padding(horizontal = 8.dp, vertical = 4.dp) Modifier.padding(horizontal = 8.dp, vertical = 8.dp)
.clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) { .clickable(onClick = { localClipboardManager.setText(AnnotatedString(address)) })) {
Column { Column {
Text(text = address, style = MaterialTheme.typography.titleMedium) Text(text = address)
Text(text = type, style = MaterialTheme.typography.bodyMedium) Text(
text = type,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
color = MaterialTheme.colorScheme.secondary)
} }
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Icon(Icons.Outlined.Share, null) Icon(Icons.Outlined.Share, null, tint = MaterialTheme.colorScheme.secondary)
} }
} }
} }
@Composable @Composable
fun ValueRow(title: String, value: String) { fun ValueRow(title: String, value: String) {
Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).fillMaxWidth()) { Row(modifier = Modifier.padding(horizontal = 8.dp, vertical = 8.dp).fillMaxWidth()) {
Text(text = title, style = MaterialTheme.typography.titleMedium) Text(text = title)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = value, style = MaterialTheme.typography.bodyMedium) Text(text = value, color = MaterialTheme.colorScheme.secondary)
} }
} }
} }

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
@ -51,29 +50,31 @@ fun Settings(
val user = viewModel.loggedInUser.collectAsState().value val user = viewModel.loggedInUser.collectAsState().value
val isAdmin = viewModel.isAdmin.collectAsState().value val isAdmin = viewModel.isAdmin.collectAsState().value
Scaffold(topBar = { Header(title = R.string.settings_title) }) { innerPadding -> Scaffold(
Column(modifier = Modifier.padding(innerPadding).fillMaxHeight()) { topBar = { Header(title = R.string.settings_title, onBack = settingsNav.onBackPressed) }) {
UserView( innerPadding ->
profile = user, Column(modifier = Modifier.padding(innerPadding).fillMaxHeight().padding(16.dp)) {
actionState = UserActionState.NAV, UserView(
onClick = viewModel.navigation.onNavigateToUserSwitcher) profile = user,
if (isAdmin) { actionState = UserActionState.NAV,
Spacer(modifier = Modifier.height(4.dp)) onClick = viewModel.navigation.onNavigateToUserSwitcher)
AdminTextView { handler.openUri(Links.ADMIN_URL) } if (isAdmin) {
} Spacer(modifier = Modifier.height(4.dp))
AdminTextView { handler.openUri(Links.ADMIN_URL) }
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
val settings = viewModel.settings.collectAsState().value val settings = viewModel.settings.collectAsState().value
settings.forEach { settingBundle -> settings.forEach { settingBundle ->
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
settingBundle.title?.let { SettingTitle(it) } settingBundle.title?.let { SettingTitle(it) }
settingBundle.settings.forEach { SettingRow(it) } settingBundle.settings.forEach { SettingRow(it) }
}
Spacer(modifier = Modifier.height(8.dp))
}
} }
Spacer(modifier = Modifier.height(8.dp))
} }
}
}
} }
@Composable @Composable
@ -142,7 +143,7 @@ fun SettingRow(setting: Setting) {
SettingType.SWITCH -> { SettingType.SWITCH -> {
Text(setting.title.getString()) Text(setting.title.getString())
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Switch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled) TintedSwitch(checked = swVal, onCheckedChange = setting.onToggle, enabled = enabled)
} }
} }
SettingType.NAV -> { SettingType.NAV -> {
@ -155,7 +156,6 @@ fun SettingRow(setting: Setting) {
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) {
Text(text = txtVal, style = MaterialTheme.typography.bodyMedium) Text(text = txtVal, style = MaterialTheme.typography.bodyMedium)
} }
ChevronRight()
} }
} }
} }

@ -4,9 +4,11 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -20,22 +22,31 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
data class BackNavigation(
val onBack: () -> Unit,
)
// Header view for all secondary screens // Header view for all secondary screens
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Header(@StringRes title: Int) { fun Header(@StringRes title: Int, onBack: (() -> Unit)? = null) {
TopAppBar( TopAppBar(
colors = colors =
TopAppBarDefaults.topAppBarColors( TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
titleContentColor = MaterialTheme.colorScheme.primary, titleContentColor = MaterialTheme.colorScheme.primary,
), ),
title = { Text(stringResource(title)) }) title = { Text(stringResource(title)) },
navigationIcon = { onBack?.let { BackArrow(action = it) } },
)
} }
@Composable @Composable
fun ChevronRight() { fun BackArrow(action: () -> Unit) {
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null) Icon(
Icons.AutoMirrored.Filled.ArrowBack,
null,
modifier = Modifier.clickable { action() }.padding(start = 15.dp, end = 20.dp))
} }
@Composable @Composable

@ -0,0 +1,24 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.runtime.Composable
import com.tailscale.ipn.ui.theme.ts_color_light_blue
@Composable
fun TintedSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, enabled: Boolean = true) {
Switch(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
colors =
SwitchDefaults.colors(
checkedBorderColor = ts_color_light_blue,
checkedThumbColor = ts_color_light_blue,
checkedTrackColor = ts_color_light_blue.copy(alpha = 0.3f),
uncheckedTrackColor = MaterialTheme.colorScheme.secondaryContainer))
}

@ -25,12 +25,12 @@ import com.tailscale.ipn.ui.viewModel.UserSwitcherViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun UserSwitcherView(viewModel: UserSwitcherViewModel = viewModel()) { fun UserSwitcherView(nav: BackNavigation, viewModel: UserSwitcherViewModel = viewModel()) {
val users = viewModel.loginProfiles.collectAsState().value val users = viewModel.loginProfiles.collectAsState().value
val currentUser = viewModel.loggedInUser.collectAsState().value val currentUser = viewModel.loggedInUser.collectAsState().value
Scaffold(topBar = { Header(R.string.accounts) }) { innerPadding -> Scaffold(topBar = { Header(R.string.accounts, onBack = nav.onBack) }) { innerPadding ->
Column( Column(
modifier = Modifier.padding(innerPadding).fillMaxWidth(), modifier = Modifier.padding(innerPadding).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)) { verticalArrangement = Arrangement.spacedBy(8.dp)) {

@ -62,7 +62,7 @@ fun UserView(
when (actionState) { when (actionState) {
UserActionState.CURRENT -> CheckedIndicator() UserActionState.CURRENT -> CheckedIndicator()
UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26) UserActionState.SWITCHING -> SimpleActivityIndicator(size = 26)
UserActionState.NAV -> ChevronRight() UserActionState.NAV -> Unit
UserActionState.NONE -> Unit UserActionState.NONE -> Unit
} }
} }

@ -78,7 +78,8 @@ data class SettingsNav(
val onNavigateToAbout: () -> Unit, val onNavigateToAbout: () -> Unit,
val onNavigateToMDMSettings: () -> Unit, val onNavigateToMDMSettings: () -> Unit,
val onNavigateToManagedBy: () -> Unit, val onNavigateToManagedBy: () -> Unit,
val onNavigateToUserSwitcher: () -> Unit val onNavigateToUserSwitcher: () -> Unit,
val onBackPressed: () -> Unit,
) )
class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory { class SettingsViewModelFactory(private val navigation: SettingsNav) : ViewModelProvider.Factory {

@ -24,13 +24,14 @@
<string name="terms_of_service">Terms of Service</string> <string name="terms_of_service">Terms of Service</string>
<string name="about_view_footnotes">WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.</string> <string name="about_view_footnotes">WireGuard is a registered trademark of Jason A. Donenfeld.\n\n© 2024 Tailscale Inc. All rights reserved.\nTailscale is a registered trademark of Tailscale Inc.</string>
<string name="app_icon_content_description">The Tailscale App Icon</string> <string name="app_icon_content_description">The Tailscale App Icon</string>
<string name="managed_by">Managed By</string>
<!-- Strings for the bug reporting screen --> <!-- Strings for the bug reporting screen -->
<string name="bug_report_title">Report a Bug</string> <string name="bug_report_title">Report a Bug</string>
<string name="bug_report_instructions_prefix">To report a bug,&#160;</string> <string name="bug_report_instructions_prefix">To report a bug,&#160;</string>
<string name="bug_report_instructions_linktext">contact our support team&#160; </string> <string name="bug_report_instructions_linktext">contact our support team&#160;</string>
<string name="bug_report_instructions_suffix">and include the ID below.</string> <string name="bug_report_instructions_suffix"> and include the ID below.</string>
<string name="bug_report_id_desc">This ID helps us find the event ino our diagnostic logs. This process does not share any of your personally-identifiable information.</string> <string name="bug_report_id_desc">This ID helps us find the event in our diagnostic logs. This process does not share any of your personally-identifiable information.</string>
<!-- Strings for the settings screen --> <!-- Strings for the settings screen -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
@ -41,16 +42,17 @@
<string name="use_ts_dns">Use Tailscale DNS</string> <string name="use_ts_dns">Use Tailscale DNS</string>
<!-- Strings for the main screen --> <!-- Strings for the main screen -->
<string name="exit_node">Exit Node</string> <string name="exit_node">EXIT NODE</string>
<string name="starting">Starting…</string> <string name="starting">Starting…</string>
<string name="connect_to_tailnet">"Connect again to talk to the other devices in the %1$s tailnet."</string> <string name="connect_to_tailnet">"Connect again to talk to the other devices in the %1$s tailnet."</string>
<string name="welcome_to_tailscale">Welcome to Tailscale</string> <string name="welcome_to_tailscale">Welcome to Tailscale</string>
<string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string> <string name="login_to_join_your_tailnet">Log in to join your tailnet and connect your devices.</string>
<!-- Strings for peer details --> <!-- Strings for peer details -->
<string name="addresses_section">TAILSCALE ADDRESSES</string> <string name="addresses_section"></string>
<string name="os">OS</string> <string name="os">OS</string>
<string name="key_expiry">Key Expiry</string> <string name="key_expiry">Key Expiry</string>
<string name="peer_details">Tailscale Addresses</string>
<!-- Strings for MDM settings --> <!-- Strings for MDM settings -->
<string name="current_mdm_settings">Current MDM Settings</string> <string name="current_mdm_settings">Current MDM Settings</string>

Loading…
Cancel
Save