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-coroutines-core:1.8.1"
implementation 'junit:junit:4.13.2'
implementation 'androidx.room:room-ktx:2.6.1'
runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"

@ -37,6 +37,7 @@
android:label="Tailscale"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:enableOnBackInvokedCallback="true"
android:theme="@style/Theme.App.SplashScreen">
<activity
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.viewModel.VpnViewModel
import com.tailscale.ipn.ui.viewModel.VpnViewModelFactory
import com.tailscale.ipn.util.TSLog
import com.tailscale.ipn.util.FeatureFlags
import com.tailscale.ipn.util.TSLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@ -192,7 +192,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
val hideDisconnectAction = MDMSettings.forceEnabled.flow.first()
}
TSLog.init(this)
FeatureFlags.initialize(mapOf("enable_new_search" to false))
FeatureFlags.initialize(mapOf("enable_new_search" to true))
}
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.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.RequiresApi
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
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.
private val loginQRCode: StateFlow<String?> = MutableStateFlow(null)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@SuppressLint("SourceLockedOrientationActivity")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -146,24 +151,37 @@ class MainActivity : ComponentActivity() {
viewModel.setVpnPermissionLauncher(vpnPermissionLauncher)
setContent {
AppTheme {
navController = rememberNavController()
AppTheme {
Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox
Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV
NavHost(
navController = navController,
startDestination = "main",
enterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it })
slideInHorizontally(
animationSpec = tween(250, easing = LinearOutSlowInEasing),
initialOffsetX = { it }) +
fadeIn(animationSpec = tween(500, easing = LinearOutSlowInEasing))
},
exitTransition = {
slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it })
slideOutHorizontally(
animationSpec = tween(250, easing = LinearOutSlowInEasing),
targetOffsetX = { -it }) +
fadeOut(animationSpec = tween(500, easing = LinearOutSlowInEasing))
},
popEnterTransition = {
slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it })
slideInHorizontally(
animationSpec = tween(250, easing = LinearOutSlowInEasing),
initialOffsetX = { -it }) +
fadeIn(animationSpec = tween(500, easing = LinearOutSlowInEasing))
},
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 = {
navController.popBackStack(route = route, inclusive = false)
@ -177,7 +195,10 @@ class MainActivity : ComponentActivity() {
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
onNavigateToHealth = { navController.navigate("health") },
onNavigateToSearch = { navController.navigate("search") })
onNavigateToSearch = {
viewModel.enableSearchAutoFocus()
navController.navigate("search")
})
val settingsNav =
SettingsNav(
@ -186,7 +207,7 @@ class MainActivity : ComponentActivity() {
onNavigateToDNSSettings = { navController.navigate("dnsSettings") },
onNavigateToSplitTunneling = { navController.navigate("splitTunneling") },
onNavigateToTailnetLock = { navController.navigate("tailnetLock") },
onNavigateToSubnetRouting = { navController.navigate("subnetRouting")},
onNavigateToSubnetRouting = { navController.navigate("subnetRouting") },
onNavigateToMDMSettings = { navController.navigate("mdmSettings") },
onNavigateToManagedBy = { navController.navigate("managedBy") },
onNavigateToUserSwitcher = { navController.navigate("userSwitcher") },
@ -219,10 +240,13 @@ class MainActivity : ComponentActivity() {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
}
composable("search") {
val autoFocus = viewModel.autoFocusSearch
SearchView(
viewModel = viewModel,
navController = navController,
onNavigateBack = { navController.popBackStack() })
onNavigateBack = { navController.popBackStack() },
autoFocus = autoFocus
)
}
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }

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

@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
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.Clear
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
@ -545,8 +545,6 @@ fun PeerList(
Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) {
Search(onSearchBarClick)
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
} else {
if (enableSearch) {
Box(
@ -748,36 +746,53 @@ fun PromptPermissionsIfNecessary() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
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
var isNavigating by remember { mutableStateOf(false) }
// Outer Box to handle clicks
Box(
modifier =
Modifier.fillMaxWidth()
.height(56.dp)
.clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surface)
.padding(top = 8.dp)) {
Box(
modifier =
Modifier.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.height(56.dp)
.clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar
.background(backgroundColor) // Search bar background
.clickable(enabled = !isNavigating) { // Intercept taps
isNavigating = true
onSearchBarClick() // Trigger navigation
onSearchBarClick()
}
.padding(horizontal = 16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
.padding(horizontal = 16.dp) // Internal padding
) {
Row(
verticalAlignment = Alignment.CenterVertically, // Ensure icon aligns with text
modifier = Modifier.fillMaxSize()) {
// Leading Icon
Icon(
imageVector = Icons.Default.Search,
contentDescription = stringResource(R.string.search),
imageVector = Icons.Outlined.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp))
Spacer(modifier = Modifier.width(8.dp))
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.bodyMedium,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f))
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
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.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.padding
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.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
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.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@ -39,55 +46,104 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.LocalSoftwareKeyboardController
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.navigation.NavController
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 kotlinx.coroutines.delay
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchView(viewModel: MainViewModel, navController: NavController, onNavigateBack: () -> Unit) {
val searchTerm by viewModel.searchTerm.collectAsState()
val filteredPeers by viewModel.peers.collectAsState()
fun SearchView(
viewModel: MainViewModel,
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 keyboardController = LocalSoftwareKeyboardController.current
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var expanded by rememberSaveable { mutableStateOf(true) }
val context = LocalContext.current as Activity
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
focusRequester.requestFocus()
keyboardController?.show()
val noResultsBackground =
if (isSystemInDarkTheme()) {
MaterialTheme.colorScheme.surface // color for dark mode
} else {
MaterialTheme.colorScheme.surfaceContainer // color for light mode
}
Column(
modifier =
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
val callback = OnBackInvokedCallback {
focusManager.clearFocus(force = true)
keyboardController?.hide()
onNavigateBack()
viewModel.updateSearchTerm("")
}
DisposableEffect(Unit) {
val dispatcher = context.onBackInvokedDispatcher
dispatcher?.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback)
onDispose { dispatcher?.unregisterOnBackInvokedCallback(callback) }
}
LaunchedEffect(searchTerm, filteredPeers) {
if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) {
delay(100) // Give Compose time to update list
listState.scrollToItem(0)
}
}
// Use the autoFocus parameter to decide if we request focus when entering.
LaunchedEffect(autoFocus) {
if (autoFocus) {
delay(300) // Delay to ensure UI is fully composed
focusRequester.requestFocus()
keyboardController?.show()
}) {
}
}
Box(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)) {
SearchBar(
modifier = Modifier.fillMaxWidth(),
query = searchTerm,
onQueryChange = { query ->
viewModel.updateSearchTerm(query)
expanded = query.isNotEmpty()
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 = { query ->
viewModel.updateSearchTerm(query)
onSearch = { newQuery ->
searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length))
viewModel.updateSearchTerm(newQuery)
focusManager.clearFocus()
keyboardController?.hide()
},
placeholder = { R.string.search },
placeholder = { Text(text = stringResource(R.string.search)) },
leadingIcon = {
IconButton(
onClick = {
focusManager.clearFocus()
onNavigateBack()
viewModel.updateSearchTerm("")
}) {
Icon(
imageVector = Icons.Default.ArrowBack,
@ -99,50 +155,82 @@ fun SearchView(viewModel: MainViewModel, navController: NavController, onNavigat
if (searchTerm.isNotEmpty()) {
IconButton(
onClick = {
searchFieldValue = TextFieldValue("", selection = TextRange(0))
viewModel.updateSearchTerm("")
focusManager.clearFocus()
keyboardController?.hide()
}) {
Icon(Icons.Default.Clear, stringResource(R.string.clear_search))
Icon(
Icons.Default.Clear,
contentDescription = stringResource(R.string.clear_search))
}
}
},
active = expanded,
onActiveChange = { expanded = it },
content = {
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
if (filteredPeers.isEmpty()) {
// When there are no filtered peers, show a "No results" message.
item {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Lists.LargeTitle(
stringResource(id = R.string.no_results),
bottomPadding = 8.dp,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Light,
backgroundColor = noResultsBackground,
fontColor = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
} else {
var firstGroup = true
filteredPeers.forEach { peerSet ->
val userName = peerSet.user?.DisplayName ?: "Unknown User"
peerSet.peers.forEach { peer ->
val deviceName = peer.displayName ?: "Unknown Device"
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
if (!firstGroup) {
item { Lists.ItemDivider() }
}
firstGroup = false
val userName = peerSet.user?.DisplayName ?: "Unknown User"
peerSet.peers.forEachIndexed { index, peer ->
if (index > 0) {
item(key = "divider_${peer.StableID}") { Lists.ItemDivider() }
}
item(key = "peer_${peer.StableID}") {
ListItem(
headlineContent = { Text(userName) },
supportingContent = {
colors = MaterialTheme.colorScheme.listItem,
headlineContent = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
val onlineColor = peer.connectedColor(netmap)
Box(
modifier =
Modifier.size(10.dp)
.background(onlineColor, shape = RoundedCornerShape(50)))
.background(onlineColor, RoundedCornerShape(50)))
Spacer(modifier = Modifier.size(8.dp))
Text(deviceName)
Text(peer.displayName ?: "Unknown Device")
}
}
Text(ipAddress)
},
supportingContent = {
Column {
Text(userName)
Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP")
}
},
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier =
Modifier.clickable {
Modifier.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 0.dp)
.clickable {
viewModel.disableSearchAutoFocus()
navController.navigate("peerDetails/${peer.StableID}")
})
}
}
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp))
}
}
}
})
}
}
}

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

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

@ -6,6 +6,9 @@ package com.tailscale.ipn.ui.viewModel
import android.content.Intent
import android.net.VpnService
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.text.AnnotatedString
import androidx.lifecycle.ViewModel
@ -44,7 +47,6 @@ class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelPr
@OptIn(FlowPreview::class)
class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
// The user readable state of the system
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())
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
val ipnState = Notifier.state
@ -70,6 +76,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
private val _searchTerm = MutableStateFlow("")
val searchTerm: StateFlow<String> = _searchTerm
var autoFocusSearch by mutableStateOf(true)
private set
// True if we should render the key expiry bannder
val showExpiry: StateFlow<Boolean> = MutableStateFlow(false)
@ -142,7 +151,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
searchJob =
launch(Dispatchers.Default) {
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) {
peerCategorizer.regenerateGroupedPeers(netmap)
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
_peers.value = filteredPeers
_peers.value = peerCategorizer.peerSets
_searchViewPeers.value = filteredPeers
}
if (netmap.SelfNode.keyDoesNotExpire) {
@ -221,6 +231,14 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
this.searchTerm.set(searchTerm)
}
fun enableSearchAutoFocus() {
autoFocusSearch = true
}
fun disableSearchAutoFocus() {
autoFocusSearch = false
}
fun setVpnPermissionLauncher(launcher: ActivityResultLauncher<Intent>) {
// No intent means we're already authorized
vpnPermissionLauncher = launcher

Loading…
Cancel
Save