Skip to content

Commit

Permalink
android: add SearchView (#584)
Browse files Browse the repository at this point in the history
-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 <[email protected]>
Signed-off-by: Nick Khyl <[email protected]>
(cherry picked from commit 45ddef1)
  • Loading branch information
kari-ts authored and nickkhyl committed Dec 11, 2024
1 parent 4bc7e78 commit a99758b
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 104 deletions.
12 changes: 10 additions & 2 deletions android/src/main/java/com/tailscale/ipn/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")) }
Expand All @@ -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())
}
Expand Down
139 changes: 39 additions & 100 deletions android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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))
}
}
}

Expand All @@ -857,6 +795,7 @@ fun MainViewPreview() {
onNavigateToSettings = {},
onNavigateToPeerDetails = {},
onNavigateToExitNodes = {},
onNavigateToHealth = {}),
onNavigateToHealth = {},
onNavigateToSearch = {}),
vm)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -90,7 +90,7 @@ fun PeerDetails(
contentDescription = "Ping device")
}
},
onBack = backToHome)
onBack = onNavigateBack)
},
) { innerPadding ->
LazyColumn(
Expand Down
Loading

0 comments on commit a99758b

Please sign in to comment.