diff --git a/android/src/main/java/com/tailscale/ipn/MainActivity.kt b/android/src/main/java/com/tailscale/ipn/MainActivity.kt index f3e451809d..118d5235b6 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 419ccda11f..49fa5eda46 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.Lock @@ -42,8 +40,6 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet 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 @@ -52,19 +48,14 @@ 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 -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -113,7 +104,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) @@ -195,7 +187,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) } } } @@ -220,7 +216,7 @@ fun MainView( PeerList( viewModel = viewModel, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, - onSearch = { viewModel.searchPeers(it) }) + onSearchBarClick = navigation.onNavigateToSearch) } Ipn.State.NoState, Ipn.State.Starting -> StartingView() @@ -523,7 +519,7 @@ fun ConnectView( fun PeerList( viewModel: MainViewModel, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, - onSearch: (String) -> Unit + onSearchBarClick: () -> Unit ) { val peerList by viewModel.peers.collectAsState(initial = emptyList()) val searchTermStr by viewModel.searchTerm.collectAsState(initial = "") @@ -531,8 +527,6 @@ fun PeerList( remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value val netmap = viewModel.netmap.collectAsState() - val focusManager = LocalFocusManager.current - var isFocussed by remember { mutableStateOf(false) } var isListFocussed by remember { mutableStateOf(false) } val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val localClipboardManager = LocalClipboardManager.current @@ -540,7 +534,7 @@ fun PeerList( Column(modifier = Modifier.fillMaxSize()) { if (enableSearch) { - SearchWithDynamicSuggestions(viewModel, onSearch) + Search(onSearchBarClick) Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp)) } @@ -653,7 +647,7 @@ fun NodesSectionHeader(peerSet: PeerSet) { Lists.LargeTitle( peerSet.user?.DisplayName ?: stringResource(id = R.string.unknown_user), bottomPadding = 8.dp, - focusable = isAndroidTV(), + focusable = true, // isAndroidTV(), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) } @@ -701,98 +695,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)) + } } } @@ -808,6 +742,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 18c2156f46..01801b9e53 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 0000000000..f73566d264 --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -0,0 +1,147 @@ +// 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.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 = "Back", + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { + IconButton( + onClick = { + viewModel.updateSearchTerm("") + focusManager.clearFocus() + keyboardController?.hide() + }) { + Icon(Icons.Default.Clear, contentDescription = "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)) + } + } + } + }) + } +} \ No newline at end of file diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml index 8d6657ea61..c2eb7df271 100644 --- a/android/src/main/res/values/strings.xml +++ b/android/src/main/res/values/strings.xml @@ -18,6 +18,7 @@ Continue Warning Search + Search... Dismiss No results