android: refine search (#611)

-improve transition
-clean up search input spacing to match other elements
-match search results page styling to machines page
-fix issue where search suggestions were propagating to main view
-flip new search flag On

Fixes tailscale/corp#18973

Signed-off-by: kari-ts <kari@tailscale.com>
pull/620/head
kari-ts 9 months ago committed by GitHub
parent 6a3342e66d
commit 10b2c61f5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -119,6 +119,7 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1"
implementation 'junit:junit:4.13.2' implementation 'junit:junit:4.13.2'
implementation 'androidx.room:room-ktx:2.6.1'
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"

@ -37,6 +37,7 @@
android:label="Tailscale" android:label="Tailscale"
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.App.SplashScreen"> android:theme="@style/Theme.App.SplashScreen">
<activity <activity
android:name="MainActivity" android:name="MainActivity"

@ -34,8 +34,8 @@ import com.tailscale.ipn.ui.notifier.HealthNotifier
import com.tailscale.ipn.ui.notifier.Notifier import com.tailscale.ipn.ui.notifier.Notifier
import com.tailscale.ipn.ui.viewModel.VpnViewModel import com.tailscale.ipn.ui.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import com.tailscale.ipn.util.FeatureFlags import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@ -192,7 +192,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first() val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
} }
TSLog.init(this) TSLog.init(this)
FeatureFlags.initialize(mapOf("enable_new_search" to false)) FeatureFlags.initialize(mapOf("enable_new_search" to true))
} }
private fun initViewModels() { private fun initViewModels() {

@ -14,13 +14,17 @@ import android.content.res.Configuration.SCREENLAYOUT_SIZE_LARGE
import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK import android.content.res.Configuration.SCREENLAYOUT_SIZE_MASK
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -111,6 +115,7 @@ class MainActivity : ComponentActivity() {
// simply opening the URL. This should be consumed once it has been handled. // simply opening the URL. This should be consumed once it has been handled.
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null) private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@SuppressLint("SourceLockedOrientationActivity") @SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -146,24 +151,37 @@ class MainActivity : ComponentActivity() {
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent { setContent {
navController = rememberNavController()
AppTheme { AppTheme {
navController = rememberNavController()
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = "main", startDestination = "main",
enterTransition = { enterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it }) slideInHorizontally(
animationSpec = tween(250, easing = LinearOutSlowInEasing),
initialOffsetX = { it }) +
fadeIn(animationSpec = tween(500, easing = LinearOutSlowInEasing))
}, },
exitTransition = { exitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it }) slideOutHorizontally(
animationSpec = tween(250, easing = LinearOutSlowInEasing),
targetOffsetX = { -it }) +
fadeOut(animationSpec = tween(500, easing = LinearOutSlowInEasing))
}, },
popEnterTransition = { popEnterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it }) slideInHorizontally(
animationSpec = tween(250, easing = LinearOutSlowInEasing),
initialOffsetX = { -it }) +
fadeIn(animationSpec = tween(500, easing = LinearOutSlowInEasing))
}, },
popExitTransition = { popExitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it }) slideOutHorizontally(
animationSpec = tween(250, easing = LinearOutSlowInEasing),
targetOffsetX = { it }) +
fadeOut(animationSpec = tween(500, easing = LinearOutSlowInEasing))
}) { }) {
fun backTo(route: String): () -> Unit = { fun backTo(route: String): () -> Unit = {
navController.popBackStack(route = route, inclusive = false) navController.popBackStack(route = route, inclusive = false)
@ -177,7 +195,10 @@ class MainActivity : ComponentActivity() {
}, },
onNavigateToExitNodes = { navController.navigate("exitNodes") }, onNavigateToExitNodes = { navController.navigate("exitNodes") },
onNavigateToHealth = { navController.navigate("health") }, onNavigateToHealth = { navController.navigate("health") },
onNavigateToSearch = { navController.navigate("search") }) onNavigateToSearch = {
viewModel.enableSearchAutoFocus()
navController.navigate("search")
})
val settingsNav = val settingsNav =
SettingsNav( SettingsNav(
@ -186,7 +207,7 @@ class MainActivity : ComponentActivity() {
onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") }, onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToSubnetRouting = { navController.navigate("subnetRouting")}, onNavigateToSubnetRouting = { navController.navigate("subnetRouting") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
@ -219,11 +240,14 @@ class MainActivity : ComponentActivity() {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
} }
composable("search") { composable("search") {
val autoFocus = viewModel.autoFocusSearch
SearchView( SearchView(
viewModel = viewModel, viewModel = viewModel,
navController = navController, navController = navController,
onNavigateBack = { navController.popBackStack() }) onNavigateBack = { navController.popBackStack() },
} autoFocus = autoFocus
)
}
composable("settings") { SettingsView(settingsNav) } composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) } composable("health") { HealthView(backTo("main")) }

@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -34,7 +35,8 @@ object Lists {
@Composable @Composable
fun ItemDivider() { fun ItemDivider() {
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.fillMaxWidth())
} }
@Composable @Composable
@ -43,20 +45,36 @@ object Lists {
bottomPadding: Dp = 0.dp, bottomPadding: Dp = 0.dp,
style: TextStyle = MaterialTheme.typography.titleMedium, style: TextStyle = MaterialTheme.typography.titleMedium,
fontWeight: FontWeight? = null, fontWeight: FontWeight? = null,
focusable: Boolean = false focusable: Boolean = false,
backgroundColor: Color = MaterialTheme.colorScheme.surface,
fontColor: Color? = null
) { ) {
Box( Box(
modifier = modifier = Modifier
Modifier.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) { .background(color = backgroundColor, shape = RectangleShape)
Text( ) {
title, if (fontColor != null) {
modifier = Text(
Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) text = title,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable), .focusable(focusable),
style = style, style = style,
fontWeight = fontWeight) fontWeight = fontWeight,
} color = fontColor
)
} else {
Text(
text = title,
modifier = Modifier
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding)
.focusable(focusable),
style = style,
fontWeight = fontWeight
)
}
}
} }
@Composable @Composable

@ -51,7 +51,7 @@ fun Avatar(
modifier = modifier =
Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) }) Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) })
.conditional( .conditional(
AndroidTVUtil.isAndroidTV() && isFocusable, AndroidTVUtil.isAndroidTV() && isFocusable,
{ {
size((size * 1.5f).dp) // Focusable area is larger than the avatar size((size * 1.5f).dp) // Focusable area is larger than the avatar
}) })

@ -53,7 +53,7 @@ enum class ErrorDialogType {
@Composable @Composable
fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) {
ErrorDialog( ErrorDialog(
title = type.title, title = type.title,
message = stringResource(id = type.message), message = stringResource(id = type.message),
buttonText = type.buttonText, buttonText = type.buttonText,
@ -68,7 +68,7 @@ fun ErrorDialog(
@StringRes buttonText: Int = R.string.ok, @StringRes buttonText: Int = R.string.ok,
onDismiss: () -> Unit = {} onDismiss: () -> Unit = {}
) { ) {
ErrorDialog( ErrorDialog(
title = title, title = title,
message = stringResource(id = message), message = stringResource(id = message),
buttonText = buttonText, buttonText = buttonText,

@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.width
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.filled.Search
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
@ -46,6 +45,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -545,8 +545,6 @@ fun PeerList(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) {
Search(onSearchBarClick) Search(onSearchBarClick)
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
} else { } else {
if (enableSearch) { if (enableSearch) {
Box( Box(
@ -748,37 +746,54 @@ fun PromptPermissionsIfNecessary() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun Search( fun Search(
onSearchBarClick: () -> Unit // Callback for navigating to SearchView onSearchBarClick: () -> Unit, // Callback for navigating to SearchView
backgroundColor: Color = MaterialTheme.colorScheme.background // Default background color
) { ) {
// Prevent multiple taps // Prevent multiple taps
var isNavigating by remember { mutableStateOf(false) } var isNavigating by remember { mutableStateOf(false) }
// Outer Box to handle clicks
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
.clickable(enabled = !isNavigating) { // Intercept taps .padding(top = 8.dp)) {
isNavigating = true Box(
onSearchBarClick() // Trigger navigation modifier =
} Modifier.fillMaxWidth()
.padding(horizontal = 16.dp)) { .padding(start = 16.dp, end = 16.dp, top = 16.dp)
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) { .height(56.dp)
Icon( .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar
imageVector = Icons.Default.Search, .background(backgroundColor) // Search bar background
contentDescription = stringResource(R.string.search), .clickable(enabled = !isNavigating) { // Intercept taps
tint = MaterialTheme.colorScheme.onSurfaceVariant, isNavigating = true
modifier = Modifier.padding(start = 16.dp)) onSearchBarClick()
Spacer(modifier = Modifier.width(8.dp)) }
// Placeholder Text .padding(horizontal = 16.dp) // Internal padding
Text( ) {
text = stringResource(R.string.search_ellipsis), Row(
style = MaterialTheme.typography.bodyMedium, verticalAlignment = Alignment.CenterVertically, // Ensure icon aligns with text
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxSize()) {
modifier = Modifier.weight(1f)) // Leading Icon
} Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier =
Modifier.padding(start = 0.dp) // Optional start padding for alignment
)
Spacer(modifier = Modifier.width(4.dp))
// Placeholder Text
Text(
text = stringResource(R.string.search_ellipsis),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f) // Ensure text takes up remaining space
)
}
}
} }
} }

@ -3,8 +3,15 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import android.app.Activity
import android.os.Build
import android.util.Log
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box 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
@ -13,9 +20,9 @@ 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.rememberScrollState import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Clear
@ -23,11 +30,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBar
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -39,110 +46,191 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.delay
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SearchView(viewModel: MainViewModel, navController: NavController, onNavigateBack: () -> Unit) { fun SearchView(
val searchTerm by viewModel.searchTerm.collectAsState() viewModel: MainViewModel,
val filteredPeers by viewModel.peers.collectAsState() navController: NavController,
onNavigateBack: () -> Unit,
autoFocus: Boolean // Pass true if coming from the main view, false otherwise.
) {
// Use TextFieldValue to preserve text and cursor position.
var searchFieldValue by
rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val searchTerm = searchFieldValue.text
val filteredPeers by viewModel.searchViewPeers.collectAsState()
val netmap by viewModel.netmap.collectAsState() val netmap by viewModel.netmap.collectAsState()
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
var expanded by rememberSaveable { mutableStateOf(true) } var expanded by rememberSaveable { mutableStateOf(true) }
val context = LocalContext.current as Activity
val listState = rememberLazyListState()
val noResultsBackground =
if (isSystemInDarkTheme()) {
MaterialTheme.colorScheme.surface // color for dark mode
} else {
MaterialTheme.colorScheme.surfaceContainer // color for light mode
}
LaunchedEffect(Unit) { val callback = OnBackInvokedCallback {
focusRequester.requestFocus() focusManager.clearFocus(force = true)
keyboardController?.show() keyboardController?.hide()
onNavigateBack()
viewModel.updateSearchTerm("")
} }
Column( DisposableEffect(Unit) {
modifier = val dispatcher = context.onBackInvokedDispatcher
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable { dispatcher?.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback)
focusRequester.requestFocus() onDispose { dispatcher?.unregisterOnBackInvokedCallback(callback) }
keyboardController?.show() }
}) {
SearchBar( LaunchedEffect(searchTerm, filteredPeers) {
modifier = Modifier.fillMaxWidth(), if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) {
query = searchTerm, delay(100) // Give Compose time to update list
onQueryChange = { query -> listState.scrollToItem(0)
viewModel.updateSearchTerm(query) }
expanded = query.isNotEmpty() }
},
onSearch = { query -> // Use the autoFocus parameter to decide if we request focus when entering.
viewModel.updateSearchTerm(query) LaunchedEffect(autoFocus) {
focusManager.clearFocus() if (autoFocus) {
keyboardController?.hide() delay(300) // Delay to ensure UI is fully composed
}, focusRequester.requestFocus()
placeholder = { R.string.search }, keyboardController?.show()
leadingIcon = { }
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)) {
SearchBar(
modifier = Modifier.fillMaxWidth(),
query = searchTerm,
onQueryChange = { newQuery ->
// Create a new TextFieldValue with updated text and set cursor to the end.
searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length))
viewModel.updateSearchTerm(newQuery)
expanded = true
},
onSearch = { newQuery ->
searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length))
viewModel.updateSearchTerm(newQuery)
focusManager.clearFocus()
keyboardController?.hide()
},
placeholder = { Text(text = stringResource(R.string.search)) },
leadingIcon = {
IconButton(
onClick = {
focusManager.clearFocus()
onNavigateBack()
viewModel.updateSearchTerm("")
}) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
},
trailingIcon = {
if (searchTerm.isNotEmpty()) {
IconButton( IconButton(
onClick = { onClick = {
searchFieldValue = TextFieldValue("", selection = TextRange(0))
viewModel.updateSearchTerm("")
focusManager.clearFocus() focusManager.clearFocus()
onNavigateBack() keyboardController?.hide()
}) { }) {
Icon( Icon(
imageVector = Icons.Default.ArrowBack, Icons.Default.Clear,
contentDescription = stringResource(R.string.search), contentDescription = stringResource(R.string.clear_search))
tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }
}, }
trailingIcon = { },
if (searchTerm.isNotEmpty()) { active = expanded,
IconButton( onActiveChange = { expanded = it },
onClick = { content = {
viewModel.updateSearchTerm("") LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
focusManager.clearFocus() if (filteredPeers.isEmpty()) {
keyboardController?.hide() // When there are no filtered peers, show a "No results" message.
}) { item {
Icon(Icons.Default.Clear, stringResource(R.string.clear_search)) Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
} Lists.LargeTitle(
} stringResource(id = R.string.no_results),
}, bottomPadding = 8.dp,
active = expanded, style = MaterialTheme.typography.bodyMedium,
onActiveChange = { expanded = it }, fontWeight = FontWeight.Light,
content = { backgroundColor = noResultsBackground,
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) { fontColor = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
var firstGroup = true
filteredPeers.forEach { peerSet -> filteredPeers.forEach { peerSet ->
val userName = peerSet.user?.DisplayName ?: "Unknown User" if (!firstGroup) {
peerSet.peers.forEach { peer -> item { Lists.ItemDivider() }
val deviceName = peer.displayName ?: "Unknown Device" }
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP" firstGroup = false
ListItem( val userName = peerSet.user?.DisplayName ?: "Unknown User"
headlineContent = { Text(userName) }, peerSet.peers.forEachIndexed { index, peer ->
supportingContent = { if (index > 0) {
Column { item(key = "divider_${peer.StableID}") { Lists.ItemDivider() }
Row(verticalAlignment = Alignment.CenterVertically) { }
val onlineColor = peer.connectedColor(netmap) item(key = "peer_${peer.StableID}") {
Box( ListItem(
modifier = colors = MaterialTheme.colorScheme.listItem,
Modifier.size(10.dp) headlineContent = {
.background(onlineColor, shape = RoundedCornerShape(50))) Column {
Spacer(modifier = Modifier.size(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) {
Text(deviceName) val onlineColor = peer.connectedColor(netmap)
Box(
modifier =
Modifier.size(10.dp)
.background(onlineColor, RoundedCornerShape(50)))
Spacer(modifier = Modifier.size(8.dp))
Text(peer.displayName ?: "Unknown Device")
}
}
},
supportingContent = {
Column {
Text(userName)
Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP")
} }
Text(ipAddress) },
} modifier =
}, Modifier.fillMaxWidth()
colors = ListItemDefaults.colors(containerColor = Color.Transparent), .padding(horizontal = 4.dp, vertical = 0.dp)
modifier = .clickable {
Modifier.clickable { viewModel.disableSearchAutoFocus()
navController.navigate("peerDetails/${peer.StableID}") navController.navigate("peerDetails/${peer.StableID}")
} })
.fillMaxWidth() }
.padding(horizontal = 16.dp, vertical = 4.dp))
} }
} }
} }
}) }
} })
}
}
} }

@ -36,99 +36,96 @@ import com.tailscale.ipn.ui.viewModel.SubnetRoutingViewModel
@Composable @Composable
fun SubnetRoutingView(backToSettings: BackNavigation, model: SubnetRoutingViewModel = viewModel()) { fun SubnetRoutingView(backToSettings: BackNavigation, model: SubnetRoutingViewModel = viewModel()) {
val subnetRoutes by model.advertisedRoutes.collectAsState() val subnetRoutes by model.advertisedRoutes.collectAsState()
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val isPresentingDialog by model.isPresentingDialog.collectAsState() val isPresentingDialog by model.isPresentingDialog.collectAsState()
val useSubnets by model.routeAll.collectAsState() val useSubnets by model.routeAll.collectAsState()
val currentError by model.currentError.collectAsState() val currentError by model.currentError.collectAsState()
Scaffold(topBar = { Scaffold(
Header(R.string.subnet_routes, onBack = backToSettings, actions = { topBar = {
IconButton(onClick = { Header(
uriHandler.openUri(SUBNET_ROUTERS_KB_URL) R.string.subnet_routes,
}) { onBack = backToSettings,
actions = {
IconButton(onClick = { uriHandler.openUri(SUBNET_ROUTERS_KB_URL) }) {
Icon( Icon(
painter = painterResource(R.drawable.info), contentDescription = stringResource( painter = painterResource(R.drawable.info),
R.string.open_kb_article contentDescription = stringResource(R.string.open_kb_article))
) }
) })
} }) { innerPadding ->
})
}) { innerPadding ->
LoadingIndicator.Wrap { LoadingIndicator.Wrap {
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
currentError?.let { currentError?.let {
item("error") { item("error") {
ErrorDialog(title = R.string.failed_to_save, message = it, onDismiss = { ErrorDialog(
model.onErrorDismissed() title = R.string.failed_to_save,
}) message = it,
} onDismiss = { model.onErrorDismissed() })
} }
item("subnetsToggle") { }
Setting.Switch(R.string.use_tailscale_subnets, isOn = useSubnets, onToggle = { item("subnetsToggle") {
LoadingIndicator.start() Setting.Switch(
model.toggleUseSubnets { LoadingIndicator.stop() } R.string.use_tailscale_subnets,
}) isOn = useSubnets,
} onToggle = {
item("subtitle") { LoadingIndicator.start()
ListItem(headlineContent = { model.toggleUseSubnets { LoadingIndicator.stop() }
Text( })
stringResource(R.string.use_tailscale_subnets_subtitle), }
modifier = Modifier.padding(bottom = 8.dp) item("subtitle") {
) ListItem(
}) headlineContent = {
} Text(
item("divider0") { stringResource(R.string.use_tailscale_subnets_subtitle),
Lists.SectionDivider() modifier = Modifier.padding(bottom = 8.dp))
} })
item(key = "header") { }
Lists.MutedHeader(stringResource(R.string.advertised_routes)) item("divider0") { Lists.SectionDivider() }
ListItem(headlineContent = { item(key = "header") {
Text( Lists.MutedHeader(stringResource(R.string.advertised_routes))
stringResource(R.string.run_as_subnet_router_header), ListItem(
modifier = Modifier.padding(vertical = 8.dp) headlineContent = {
) Text(
}) stringResource(R.string.run_as_subnet_router_header),
} modifier = Modifier.padding(vertical = 8.dp))
})
itemsWithDividers(subnetRoutes, key = { it }) { }
SubnetRouteRowView(route = it, onEdit = {
model.startEditingRoute(it)
}, onDelete = {
model.deleteRoute(it)
}, modifier = Modifier.animateItem())
}
item("addNewRoute") { itemsWithDividers(subnetRoutes, key = { it }) {
Lists.ItemDivider() SubnetRouteRowView(
ListItem(headlineContent = { route = it,
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { onEdit = { model.startEditingRoute(it) },
Icon(Icons.Outlined.Add, contentDescription = null) onDelete = { model.deleteRoute(it) },
Text(stringResource(R.string.add_new_route)) modifier = Modifier.animateItem())
}
}, modifier = Modifier.clickable { model.startEditingRoute("") })
}
} }
}
}
if (isPresentingDialog) { item("addNewRoute") {
Dialog(onDismissRequest = { Lists.ItemDivider()
model.isPresentingDialog.set(false) ListItem(
}) { headlineContent = {
Card { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
EditSubnetRouteDialogView(valueFlow = model.dialogTextFieldValue, Icon(Icons.Outlined.Add, contentDescription = null)
isValueValidFlow = model.isTextFieldValueValid, Text(stringResource(R.string.add_new_route))
onValueChange = { }
model.dialogTextFieldValue.set(it) },
}, modifier = Modifier.clickable { model.startEditingRoute("") })
onCommit = {
model.doneEditingRoute(newValue = it)
},
onCancel = {
model.stopEditingRoute()
})
} }
}
} }
}
if (isPresentingDialog) {
Dialog(onDismissRequest = { model.isPresentingDialog.set(false) }) {
Card {
EditSubnetRouteDialogView(
valueFlow = model.dialogTextFieldValue,
isValueValidFlow = model.isTextFieldValueValid,
onValueChange = { model.dialogTextFieldValue.set(it) },
onCommit = { model.doneEditingRoute(newValue = it) },
onCancel = { model.stopEditingRoute() })
}
} }
} }
}

@ -52,7 +52,7 @@ fun UserView(
ListItem( ListItem(
modifier = modifier, modifier = modifier,
colors = colors, colors = colors,
leadingContent = { Avatar(profile = profile, size = 36, isFocusable = false) }, leadingContent = { Avatar(profile = profile, size = 36) },
headlineContent = { headlineContent = {
AutoResizingText( AutoResizingText(
text = profile.UserProfile.LoginName, text = profile.UserProfile.LoginName,

@ -6,6 +6,9 @@ package com.tailscale.ipn.ui.viewModel
import android.content.Intent import android.content.Intent
import android.net.VpnService import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
@ -44,7 +47,6 @@ class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelPr
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// The user readable state of the system // The user readable state of the system
val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) val stateRes: StateFlow<Int> = MutableStateFlow(userStringRes(State.NoState, State.NoState, true))
@ -63,6 +65,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList()) private val _peers = MutableStateFlow<List<PeerSet>>(emptyList())
val peers: StateFlow<List<PeerSet>> = _peers val peers: StateFlow<List<PeerSet>> = _peers
// The list of peers
private val _searchViewPeers = MutableStateFlow<List<PeerSet>>(emptyList())
val searchViewPeers: StateFlow<List<PeerSet>> = _searchViewPeers
// The current state of the IPN for determining view visibility // The current state of the IPN for determining view visibility
val ipnState = Notifier.state val ipnState = Notifier.state
@ -70,6 +76,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private val _searchTerm = MutableStateFlow("") private val _searchTerm = MutableStateFlow("")
val searchTerm: StateFlow<String> = _searchTerm val searchTerm: StateFlow<String> = _searchTerm
var autoFocusSearch by mutableStateOf(true)
private set
// True if we should render the key expiry bannder // True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false) val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
@ -142,7 +151,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
searchJob = searchJob =
launch(Dispatchers.Default) { launch(Dispatchers.Default) {
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
_peers.value = filteredPeers _searchViewPeers.value = filteredPeers
} }
} }
} }
@ -154,7 +163,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
launch(Dispatchers.Default) { launch(Dispatchers.Default) {
peerCategorizer.regenerateGroupedPeers(netmap) peerCategorizer.regenerateGroupedPeers(netmap)
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
_peers.value = filteredPeers _peers.value = peerCategorizer.peerSets
_searchViewPeers.value = filteredPeers
} }
if (netmap.SelfNode.keyDoesNotExpire) { if (netmap.SelfNode.keyDoesNotExpire) {
@ -221,6 +231,14 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
this.searchTerm.set(searchTerm) this.searchTerm.set(searchTerm)
} }
fun enableSearchAutoFocus() {
autoFocusSearch = true
}
fun disableSearchAutoFocus() {
autoFocusSearch = false
}
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) { fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized // No intent means we're already authorized
vpnPermissionLauncher = launcher vpnPermissionLauncher = launcher

Loading…
Cancel
Save