Skip to content

Commit

Permalink
android: add SearchView
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]>
  • Loading branch information
kari-ts committed Dec 6, 2024
1 parent bbe3270 commit 77eb345
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 111 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
149 changes: 42 additions & 107 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.Lock
Expand All @@ -42,8 +40,6 @@ import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
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 @@ -52,19 +48,14 @@ 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
Expand Down Expand Up @@ -113,7 +104,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 @@ -195,7 +187,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 All @@ -220,7 +216,7 @@ fun MainView(
PeerList(
viewModel = viewModel,
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
onSearch = { viewModel.searchPeers(it) })
onSearchBarClick = navigation.onNavigateToSearch)
}
Ipn.State.NoState,
Ipn.State.Starting -> StartingView()
Expand Down Expand Up @@ -523,24 +519,22 @@ fun ConnectView(
fun PeerList(
viewModel: MainViewModel,
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
onSearch: (String) -> Unit
onSearchBarClick: () -> Unit
) {
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
val showNoResults =
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value

val netmap = viewModel.netmap.collectAsState()
val focusManager = LocalFocusManager.current
var isFocussed by remember { mutableStateOf(false) }
var isListFocussed by remember { mutableStateOf(false) }
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
val localClipboardManager = LocalClipboardManager.current
val enableSearch = !isAndroidTV()

Column(modifier = Modifier.fillMaxSize()) {
if (enableSearch) {
SearchWithDynamicSuggestions(viewModel, onSearch)
Search(onSearchBarClick)

Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
}
Expand Down Expand Up @@ -701,98 +695,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 @@ -808,6 +742,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 77eb345

Please sign in to comment.