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 <kari@tailscale.com>pull/585/head
parent
bbe3270c51
commit
45ddef1a90
@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue