android: create SearchView

The Material3 search bar doesn't open up a full screen view, but opens a view underneath the bar. To accomplish the full screen search page, we create a new SearchView and use a decoy search bar on the main view that navigates the user to the SearchView
Tapping on a search suggestion / result navigates to the PeerDetails, so this also updates the navigation for PeerDetails to use a back stack instead of always navigating back to the main screen

Updates tailscale/corp#18973

Signed-off-by: kari-ts <kari@tailscale.com>
kari/search
kari-ts 1 year ago
parent 3aaff20959
commit 6d07721cf9

@ -88,6 +88,7 @@ import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.warningButton import com.tailscale.ipn.ui.theme.warningButton
import com.tailscale.ipn.ui.theme.warningListItem import com.tailscale.ipn.ui.theme.warningListItem
import com.tailscale.ipn.ui.util.AndroidTVUtil.isAndroidTV
import com.tailscale.ipn.ui.util.AutoResizingText import com.tailscale.ipn.ui.util.AutoResizingText
import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.util.LoadingIndicator import com.tailscale.ipn.ui.util.LoadingIndicator
@ -137,7 +138,7 @@ fun MainView(
val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false) val showKeyExpiry by viewModel.showExpiry.collectAsState(initial = false)
// Hide the header only on Android TV when the user needs to login // Hide the header only on Android TV when the user needs to login
val hideHeader = (/*isAndroidTV() && */ state == Ipn.State.NeedsLogin) val hideHeader = (isAndroidTV() && state == Ipn.State.NeedsLogin)
ListItem( ListItem(
colors = MaterialTheme.colorScheme.surfaceContainerListItem, colors = MaterialTheme.colorScheme.surfaceContainerListItem,
@ -529,11 +530,11 @@ fun PeerList(
var isListFocussed by remember { mutableStateOf(false) } var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState() val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current val localClipboardManager = LocalClipboardManager.current
val enableSearch = true // !isAndroidTV() val enableSearch = !isAndroidTV()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch) { if (enableSearch) {
SearchWithDynamicSuggestions(viewModel, onSearchBarClick) Search(onSearchBarClick)
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp)) Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
} }
@ -570,11 +571,11 @@ fun PeerList(
} }
first = false first = false
// if (isAndroidTV()) { if (isAndroidTV()) {
item { NodesSectionHeader(peerSet = peerSet) } item { NodesSectionHeader(peerSet = peerSet) }
/* } else { } else {
stickyHeader { NodesSectionHeader(peerSet = peerSet) } stickyHeader { NodesSectionHeader(peerSet = peerSet) }
}*/ }
itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer -> itemsWithDividers(peerSet.peers, key = { it.StableID }) { peer ->
ListItem( ListItem(
@ -694,8 +695,7 @@ fun PromptPermissionsIfNecessary() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SearchWithDynamicSuggestions( fun Search(
viewModel: MainViewModel,
onSearchBarClick: () -> Unit // Callback for navigating to SearchView onSearchBarClick: () -> Unit // Callback for navigating to SearchView
) { ) {
// Prevent multiple taps // Prevent multiple taps
@ -705,30 +705,27 @@ fun SearchWithDynamicSuggestions(
Box( Box(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
.height(56.dp) // Height matching Material Design search bar .height(56.dp)
.clip(RoundedCornerShape(28.dp)) // Fully rounded edges .clip(RoundedCornerShape(28.dp))
.background(MaterialTheme.colorScheme.surface) // Surface background .background(MaterialTheme.colorScheme.surface)
.clickable(enabled = !isNavigating) { // Intercept taps .clickable(enabled = !isNavigating) { // Intercept taps
isNavigating = true isNavigating = true
onSearchBarClick() // Trigger navigation onSearchBarClick() // Trigger navigation
} }
.padding(horizontal = 16.dp) // Padding for a clean look .padding(horizontal = 16.dp)) {
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
// Search Icon
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Default.Search,
contentDescription = "Search", contentDescription = stringResource(R.string.search),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 16.dp)) modifier = Modifier.padding(start = 16.dp))
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// Placeholder Text // Placeholder Text
Text( Text(
text = "Search...", text = stringResource(R.string.search_ellipsis),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f) // Fill remaining space modifier = Modifier.weight(1f))
)
} }
} }
} }

@ -44,15 +44,12 @@ import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.viewModel.MainViewModel import com.tailscale.ipn.ui.viewModel.MainViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SearchView( fun SearchView(viewModel: MainViewModel, navController: NavController, onNavigateBack: () -> Unit) {
viewModel: MainViewModel,
navController: NavController, // Use NavController for navigation
onNavigateBack: () -> Unit
) {
val searchTerm by viewModel.searchTerm.collectAsState() val searchTerm by viewModel.searchTerm.collectAsState()
val filteredPeers by viewModel.peers.collectAsState() val filteredPeers by viewModel.peers.collectAsState()
val netmap by viewModel.netmap.collectAsState() val netmap by viewModel.netmap.collectAsState()
@ -84,7 +81,7 @@ fun SearchView(
focusManager.clearFocus() focusManager.clearFocus()
keyboardController?.hide() keyboardController?.hide()
}, },
placeholder = { Text("Search") }, placeholder = { R.string.search },
leadingIcon = { leadingIcon = {
IconButton( IconButton(
onClick = { onClick = {
@ -138,7 +135,7 @@ fun SearchView(
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = modifier =
Modifier.clickable { Modifier.clickable {
navController.navigate("peerDetails/${peer.StableID}") navController.navigate("peerDetails/${peer.StableID}")
} }
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)) .padding(horizontal = 16.dp, vertical = 4.dp))

@ -18,6 +18,7 @@
<string name="_continue">Continue</string> <string name="_continue">Continue</string>
<string name="warning">Warning</string> <string name="warning">Warning</string>
<string name="search">Search</string> <string name="search">Search</string>
<string name="search_ellipsis">Search...</string>
<string name="dismiss">Dismiss</string> <string name="dismiss">Dismiss</string>
<string name="no_results">No results</string> <string name="no_results">No results</string>

Loading…
Cancel
Save