From b96a4c9dde597756cce2fa7db1fb8427760e659a Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Fri, 6 Dec 2024 15:51:28 -0800 Subject: [PATCH] android: put new search bar behind flag This hides WIP search bar until the feature is fully ready. Updates tailscale/corp#18973 Signed-off-by: kari-ts --- .../java/com/tailscale/ipn/MainActivity.kt | 12 +- .../com/tailscale/ipn/ui/view/MainView.kt | 196 ++++++++---------- .../com/tailscale/ipn/ui/view/PeerDetails.kt | 4 +- .../com/tailscale/ipn/ui/view/SearchView.kt | 148 +++++++++++++ android/src/main/res/values/strings.xml | 3 + 5 files changed, 255 insertions(+), 108 deletions(-) create mode 100644 android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt 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 419ccda..574fab6 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,15 +21,16 @@ 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 +import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Lock +import androidx.compose.material.icons.outlined.Search import androidx.compose.material.icons.outlined.Settings import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu @@ -41,9 +42,8 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.ListItemDefaults 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 @@ -52,19 +52,15 @@ 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 @@ -92,6 +88,7 @@ import com.tailscale.ipn.ui.theme.exitNodeToggleButton import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.theme.minTextSize import com.tailscale.ipn.ui.theme.primaryListItem +import com.tailscale.ipn.ui.theme.searchBarColors import com.tailscale.ipn.ui.theme.secondaryButton import com.tailscale.ipn.ui.theme.short import com.tailscale.ipn.ui.theme.surfaceContainerListItem @@ -113,7 +110,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 +193,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,6 +222,7 @@ fun MainView( PeerList( viewModel = viewModel, onNavigateToPeerDetails = navigation.onNavigateToPeerDetails, + onSearchBarClick = navigation.onNavigateToSearch, onSearch = { viewModel.searchPeers(it) }) } Ipn.State.NoState, @@ -523,6 +526,7 @@ fun ConnectView( fun PeerList( viewModel: MainViewModel, onNavigateToPeerDetails: (Tailcfg.Node) -> Unit, + onSearchBarClick: () -> Unit, onSearch: (String) -> Unit ) { val peerList by viewModel.peers.collectAsState(initial = emptyList()) @@ -532,17 +536,60 @@ fun PeerList( val netmap = viewModel.netmap.collectAsState() val focusManager = LocalFocusManager.current - var isFocussed by remember { mutableStateOf(false) } + var isSearchFocussed by remember { mutableStateOf(false) } var isListFocussed by remember { mutableStateOf(false) } val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val localClipboardManager = LocalClipboardManager.current val enableSearch = !isAndroidTV() + val enableNewSearch = false // This enables new search page with suggestions. Column(modifier = Modifier.fillMaxSize()) { - if (enableSearch) { - SearchWithDynamicSuggestions(viewModel, onSearch) + if (enableSearch && enableNewSearch) { + Search(onSearchBarClick) Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp)) + } else { + if (enableSearch) { + Box( + modifier = + Modifier.fillMaxWidth().background(color = MaterialTheme.colorScheme.surface)) { + OutlinedTextField( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 0.dp) + .onFocusChanged { isSearchFocussed = it.isFocused }, + singleLine = true, + shape = MaterialTheme.shapes.extraLarge, + colors = MaterialTheme.colorScheme.searchBarColors, + leadingIcon = { + Icon(imageVector = Icons.Outlined.Search, contentDescription = "search") + }, + trailingIcon = { + if (isSearchFocussed) { + IconButton( + onClick = { + focusManager.clearFocus() + onSearch("") + }) { + Icon( + imageVector = + if (searchTermStr.isEmpty()) Icons.Outlined.Close + else Icons.Outlined.Clear, + contentDescription = "clear search", + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + }, + placeholder = { + Text( + text = stringResource(id = R.string.search), + style = MaterialTheme.typography.bodyLarge, + maxLines = 1) + }, + value = searchTermStr, + onValueChange = { onSearch(it) }) + } + } } // Peers display @@ -701,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)) + } } } @@ -808,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