diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
index f3e4518..118d523 100644
--- a/android/src/main/java/com/tailscale/ipn/MainActivity.kt
+++ b/android/src/main/java/com/tailscale/ipn/MainActivity.kt
@@ -66,6 +66,7 @@ import com.tailscale.ipn.ui.view.MullvadInfoView
import com.tailscale.ipn.ui.view.PeerDetails
import com.tailscale.ipn.ui.view.PermissionsView
import com.tailscale.ipn.ui.view.RunExitNodeView
+import com.tailscale.ipn.ui.view.SearchView
import com.tailscale.ipn.ui.view.SettingsView
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
import com.tailscale.ipn.ui.view.TailnetLockSetupView
@@ -174,7 +175,8 @@ class MainActivity : ComponentActivity() {
navController.navigate("peerDetails/${it.StableID}")
},
onNavigateToExitNodes = { navController.navigate("exitNodes") },
- onNavigateToHealth = { navController.navigate("health") })
+ onNavigateToHealth = { navController.navigate("health") },
+ onNavigateToSearch = { navController.navigate("search") })
val settingsNav =
SettingsNav(
@@ -214,6 +216,12 @@ class MainActivity : ComponentActivity() {
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
}
+ composable("search") {
+ SearchView(
+ viewModel = viewModel,
+ navController = navController,
+ onNavigateBack = { navController.popBackStack() })
+ }
composable("settings") { SettingsView(settingsNav) }
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
composable("health") { HealthView(backTo("main")) }
@@ -231,7 +239,7 @@ class MainActivity : ComponentActivity() {
"peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
PeerDetails(
- backTo("main"),
+ { navController.popBackStack() },
it.arguments?.getString("nodeId") ?: "",
PingViewModel())
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
index ef07e96..0821eef 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
@@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
+import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
-import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
@@ -46,8 +44,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.SearchBar
-import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -56,14 +52,11 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
-import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.focus.FocusRequester
-import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
@@ -118,7 +111,8 @@ data class MainViewNavigation(
val onNavigateToSettings: () -> Unit,
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
val onNavigateToExitNodes: () -> Unit,
- val onNavigateToHealth: () -> Unit
+ val onNavigateToHealth: () -> Unit,
+ val onNavigateToSearch: () -> Unit,
)
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@@ -200,7 +194,11 @@ fun MainView(
when (user) {
null -> SettingsButton { navigation.onNavigateToSettings() }
else -> {
- Avatar(profile = user, size = 36, { navigation.onNavigateToSettings() }, isFocusable=true)
+ Avatar(
+ profile = user,
+ size = 36,
+ { navigation.onNavigateToSettings() },
+ isFocusable = true)
}
}
}
@@ -750,98 +748,38 @@ fun PromptPermissionsIfNecessary() {
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SearchWithDynamicSuggestions(viewModel: MainViewModel, onSearch: (String) -> Unit) {
- val searchTerm by viewModel.searchTerm.collectAsState()
- val filteredPeers by viewModel.peers.collectAsState()
- var expanded by rememberSaveable { mutableStateOf(false) }
- val netmap by viewModel.netmap.collectAsState()
-
- val keyboardController = LocalSoftwareKeyboardController.current
- val focusRequester = remember { FocusRequester() }
- val focusManager = LocalFocusManager.current
+fun Search(
+ onSearchBarClick: () -> Unit // Callback for navigating to SearchView
+) {
+ // Prevent multiple taps
+ var isNavigating by remember { mutableStateOf(false) }
- Column(
+ // Outer Box to handle clicks
+ Box(
modifier =
- Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
- focusRequester.requestFocus()
- keyboardController?.show()
- }) {
- SearchBar(
- modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
- inputField = {
- SearchBarDefaults.InputField(
- query = searchTerm,
- onQueryChange = { query ->
- viewModel.updateSearchTerm(query)
- onSearch(query)
- expanded = query.isNotEmpty()
- },
- onSearch = { query ->
- viewModel.updateSearchTerm(query)
- onSearch(query)
- expanded = false
- },
- expanded = expanded,
- onExpandedChange = { expanded = it },
- placeholder = { Text("Search") },
- leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
- trailingIcon = {
- if (expanded) {
- IconButton(
- onClick = {
- viewModel.updateSearchTerm("")
- onSearch("")
- expanded = false
- focusManager.clearFocus()
- keyboardController?.hide()
- }) {
- Icon(Icons.Default.Clear, contentDescription = "Clear search")
- }
- }
- })
- },
- expanded = expanded,
- onExpandedChange = { expanded = it },
- content = {
- // Search results or suggestions
- Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
- 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"
-
- ListItem(
- headlineContent = { Text(userName) },
- supportingContent = {
- Column {
- Row(verticalAlignment = Alignment.CenterVertically) {
- val onlineColor = peer.connectedColor(netmap)
- Box(
- modifier =
- Modifier.size(10.dp)
- .background(onlineColor, shape = RoundedCornerShape(50)))
- Spacer(modifier = Modifier.size(8.dp))
- Text(deviceName)
- }
- Text(ipAddress)
- }
- },
- colors = ListItemDefaults.colors(containerColor = Color.Transparent),
- modifier =
- Modifier.clickable {
- viewModel.updateSearchTerm(userName)
- onSearch(userName)
- expanded = false
- focusManager.clearFocus()
- keyboardController?.hide()
- }
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 4.dp))
- }
- }
+ Modifier.fillMaxWidth()
+ .height(56.dp)
+ .clip(RoundedCornerShape(28.dp))
+ .background(MaterialTheme.colorScheme.surface)
+ .clickable(enabled = !isNavigating) { // Intercept taps
+ isNavigating = true
+ onSearchBarClick() // Trigger navigation
}
- })
+ .padding(horizontal = 16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = stringResource(R.string.search),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 16.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ // Placeholder Text
+ Text(
+ text = stringResource(R.string.search_ellipsis),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f))
+ }
}
}
@@ -857,6 +795,7 @@ fun MainViewPreview() {
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {},
- onNavigateToHealth = {}),
+ onNavigateToHealth = {},
+ onNavigateToSearch = {}),
vm)
}
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt
index 18c2156..01801b9 100644
--- a/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt
@@ -47,7 +47,7 @@ import com.tailscale.ipn.ui.viewModel.PingViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PeerDetails(
- backToHome: BackNavigation,
+ onNavigateBack: () -> Unit,
nodeId: String,
pingViewModel: PingViewModel,
model: PeerDetailsViewModel =
@@ -90,7 +90,7 @@ fun PeerDetails(
contentDescription = "Ping device")
}
},
- onBack = backToHome)
+ onBack = onNavigateBack)
},
) { innerPadding ->
LazyColumn(
diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt
new file mode 100644
index 0000000..f75aabb
--- /dev/null
+++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt
@@ -0,0 +1,148 @@
+// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package com.tailscale.ipn.ui.view
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.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
+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.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+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.LocalFocusManager
+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.navigation.NavController
+import com.tailscale.ipn.R
+import com.tailscale.ipn.ui.viewModel.MainViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchView(viewModel: MainViewModel, navController: NavController, onNavigateBack: () -> Unit) {
+ val searchTerm by viewModel.searchTerm.collectAsState()
+ val filteredPeers by viewModel.peers.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) }
+
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ keyboardController?.show()
+ }
+
+ Column(
+ modifier =
+ Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
+ focusRequester.requestFocus()
+ keyboardController?.show()
+ }) {
+ SearchBar(
+ modifier = Modifier.fillMaxWidth(),
+ query = searchTerm,
+ onQueryChange = { query ->
+ viewModel.updateSearchTerm(query)
+ expanded = query.isNotEmpty()
+ },
+ onSearch = { query ->
+ viewModel.updateSearchTerm(query)
+ focusManager.clearFocus()
+ keyboardController?.hide()
+ },
+ placeholder = { R.string.search },
+ leadingIcon = {
+ IconButton(
+ onClick = {
+ focusManager.clearFocus()
+ onNavigateBack()
+ }) {
+ Icon(
+ imageVector = Icons.Default.ArrowBack,
+ contentDescription = stringResource(R.string.search),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ },
+ trailingIcon = {
+ if (searchTerm.isNotEmpty()) {
+ IconButton(
+ onClick = {
+ viewModel.updateSearchTerm("")
+ focusManager.clearFocus()
+ keyboardController?.hide()
+ }) {
+ Icon(Icons.Default.Clear, stringResource(R.string.clear_search))
+ }
+ }
+ },
+ active = expanded,
+ onActiveChange = { expanded = it },
+ content = {
+ Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
+ 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"
+
+ ListItem(
+ headlineContent = { Text(userName) },
+ supportingContent = {
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val onlineColor = peer.connectedColor(netmap)
+ Box(
+ modifier =
+ Modifier.size(10.dp)
+ .background(onlineColor, shape = RoundedCornerShape(50)))
+ Spacer(modifier = Modifier.size(8.dp))
+ Text(deviceName)
+ }
+ Text(ipAddress)
+ }
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ modifier =
+ Modifier.clickable {
+ navController.navigate("peerDetails/${peer.StableID}")
+ }
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp))
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml
index 8d6657e..b315df9 100644
--- a/android/src/main/res/values/strings.xml
+++ b/android/src/main/res/values/strings.xml
@@ -18,8 +18,11 @@
Continue
Warning
Search
+ Search...
Dismiss
No results
+ Back
+ Clear search
Tailscale