From 97f237de36a0cb6b9951183746209b53fa66cca0 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: add SearchView (#584) -Material3 search bar opens up search suggestions/results view under the bar, but we want it to open up a full page, so create SearchView and use placeholder search bar on MainView that navigates to SearchView -Tapping on suggestions/results should open up PeerDetails, so fix PeerDetails navigation to use backstack instead of always going back to Main view Next up: ensuring search filtering adheres to MDM requirements, and UI polish Updates tailscale/corp#18973 Signed-off-by: kari-ts (cherry picked from commit 45ddef1a9049ab3f910b54d4e03a2d6cef2a0c11) --- .../java/com/tailscale/ipn/MainActivity.kt | 12 +- .../com/tailscale/ipn/ui/view/MainView.kt | 139 +++++----------- .../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, 202 insertions(+), 104 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 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 ef07e9654a..0821eefb1e 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 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..f75aabb51a --- /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 8d6657ea61..b315df9319 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