From 10b2c61f5fe5dced700059fc46a9027c8f0c589a Mon Sep 17 00:00:00 2001 From: kari-ts <135075563+kari-ts@users.noreply.github.com> Date: Thu, 6 Mar 2025 09:25:05 -0800 Subject: [PATCH] android: refine search (#611) -improve transition -clean up search input spacing to match other elements -match search results page styling to machines page -fix issue where search suggestions were propagating to main view -flip new search flag On Fixes tailscale/corp#18973 Signed-off-by: kari-ts --- android/build.gradle | 1 + android/src/main/AndroidManifest.xml | 1 + .../src/main/java/com/tailscale/ipn/App.kt | 4 +- .../java/com/tailscale/ipn/MainActivity.kt | 42 ++- .../java/com/tailscale/ipn/ui/util/Lists.kt | 44 +++- .../java/com/tailscale/ipn/ui/view/Avatar.kt | 2 +- .../com/tailscale/ipn/ui/view/ErrorDialog.kt | 4 +- .../com/tailscale/ipn/ui/view/MainView.kt | 67 +++-- .../com/tailscale/ipn/ui/view/SearchView.kt | 246 ++++++++++++------ .../ipn/ui/view/SubnetRoutingView.kt | 171 ++++++------ .../com/tailscale/ipn/ui/view/UserView.kt | 2 +- .../ipn/ui/viewModel/MainViewModel.kt | 24 +- 12 files changed, 385 insertions(+), 223 deletions(-) diff --git a/android/build.gradle b/android/build.gradle index e68f0ef..4d1bcfe 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -119,6 +119,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1" implementation 'junit:junit:4.13.2' + implementation 'androidx.room:room-ktx:2.6.1' runtimeOnly "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version" diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index b5776a6..92cb0de 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -37,6 +37,7 @@ android:label="Tailscale" android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" + android:enableOnBackInvokedCallback="true" android:theme="@style/Theme.App.SplashScreen"> = MutableStateFlow(null) + @RequiresApi(Build.VERSION_CODES.TIRAMISU) @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -146,24 +151,37 @@ class MainActivity : ComponentActivity() { viewModel.setVpnPermissionLauncher(vpnPermissionLauncher) setContent { + navController = rememberNavController() + AppTheme { - navController = rememberNavController() Surface(color = MaterialTheme.colorScheme.inverseSurface) { // Background for the letterbox Surface(modifier = Modifier.universalFit()) { // Letterbox for AndroidTV NavHost( navController = navController, startDestination = "main", enterTransition = { - slideInHorizontally(animationSpec = tween(150), initialOffsetX = { it }) + slideInHorizontally( + animationSpec = tween(250, easing = LinearOutSlowInEasing), + initialOffsetX = { it }) + + fadeIn(animationSpec = tween(500, easing = LinearOutSlowInEasing)) }, exitTransition = { - slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { -it }) + slideOutHorizontally( + animationSpec = tween(250, easing = LinearOutSlowInEasing), + targetOffsetX = { -it }) + + fadeOut(animationSpec = tween(500, easing = LinearOutSlowInEasing)) }, popEnterTransition = { - slideInHorizontally(animationSpec = tween(150), initialOffsetX = { -it }) + slideInHorizontally( + animationSpec = tween(250, easing = LinearOutSlowInEasing), + initialOffsetX = { -it }) + + fadeIn(animationSpec = tween(500, easing = LinearOutSlowInEasing)) }, popExitTransition = { - slideOutHorizontally(animationSpec = tween(150), targetOffsetX = { it }) + slideOutHorizontally( + animationSpec = tween(250, easing = LinearOutSlowInEasing), + targetOffsetX = { it }) + + fadeOut(animationSpec = tween(500, easing = LinearOutSlowInEasing)) }) { fun backTo(route: String): () -> Unit = { navController.popBackStack(route = route, inclusive = false) @@ -177,7 +195,10 @@ class MainActivity : ComponentActivity() { }, onNavigateToExitNodes = { navController.navigate("exitNodes") }, onNavigateToHealth = { navController.navigate("health") }, - onNavigateToSearch = { navController.navigate("search") }) + onNavigateToSearch = { + viewModel.enableSearchAutoFocus() + navController.navigate("search") + }) val settingsNav = SettingsNav( @@ -186,7 +207,7 @@ class MainActivity : ComponentActivity() { onNavigateToDNSSettings = { navController.navigate("dnsSettings") }, onNavigateToSplitTunneling = { navController.navigate("splitTunneling") }, onNavigateToTailnetLock = { navController.navigate("tailnetLock") }, - onNavigateToSubnetRouting = { navController.navigate("subnetRouting")}, + onNavigateToSubnetRouting = { navController.navigate("subnetRouting") }, onNavigateToMDMSettings = { navController.navigate("mdmSettings") }, onNavigateToManagedBy = { navController.navigate("managedBy") }, onNavigateToUserSwitcher = { navController.navigate("userSwitcher") }, @@ -219,11 +240,14 @@ class MainActivity : ComponentActivity() { MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel) } composable("search") { + val autoFocus = viewModel.autoFocusSearch SearchView( viewModel = viewModel, navController = navController, - onNavigateBack = { navController.popBackStack() }) - } + onNavigateBack = { navController.popBackStack() }, + autoFocus = autoFocus + ) + } composable("settings") { SettingsView(settingsNav) } composable("exitNodes") { ExitNodePicker(exitNodePickerNav) } composable("health") { HealthView(backTo("main")) } diff --git a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt index 4da762d..696158c 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/util/Lists.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle @@ -34,7 +35,8 @@ object Lists { @Composable fun ItemDivider() { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.fillMaxWidth()) } @Composable @@ -43,20 +45,36 @@ object Lists { bottomPadding: Dp = 0.dp, style: TextStyle = MaterialTheme.typography.titleMedium, fontWeight: FontWeight? = null, - focusable: Boolean = false + focusable: Boolean = false, + backgroundColor: Color = MaterialTheme.colorScheme.surface, + fontColor: Color? = null ) { - Box( - modifier = - Modifier.fillMaxWidth() - .background(color = MaterialTheme.colorScheme.surface, shape = RectangleShape)) { - Text( - title, - modifier = - Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) + Box( + modifier = Modifier + .fillMaxWidth() + .background(color = backgroundColor, shape = RectangleShape) + ) { + if (fontColor != null) { + Text( + text = title, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) .focusable(focusable), - style = style, - fontWeight = fontWeight) - } + style = style, + fontWeight = fontWeight, + color = fontColor + ) + } else { + Text( + text = title, + modifier = Modifier + .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = bottomPadding) + .focusable(focusable), + style = style, + fontWeight = fontWeight + ) + } + } } @Composable diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt index 54adf27..1d59d91 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/Avatar.kt @@ -51,7 +51,7 @@ fun Avatar( modifier = Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) }) .conditional( - AndroidTVUtil.isAndroidTV() && isFocusable, + AndroidTVUtil.isAndroidTV() && isFocusable, { size((size * 1.5f).dp) // Focusable area is larger than the avatar }) diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt index 36b7596..c79d5bf 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/ErrorDialog.kt @@ -53,7 +53,7 @@ enum class ErrorDialogType { @Composable fun ErrorDialog(type: ErrorDialogType, action: () -> Unit = {}) { - ErrorDialog( + ErrorDialog( title = type.title, message = stringResource(id = type.message), buttonText = type.buttonText, @@ -68,7 +68,7 @@ fun ErrorDialog( @StringRes buttonText: Int = R.string.ok, onDismiss: () -> Unit = {} ) { - ErrorDialog( + ErrorDialog( title = title, message = stringResource(id = message), buttonText = buttonText, 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 538e67e..b3d94f7 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 @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.ArrowDropDown import androidx.compose.material.icons.outlined.Clear import androidx.compose.material.icons.outlined.Close @@ -46,6 +45,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -545,8 +545,6 @@ fun PeerList( Column(modifier = Modifier.fillMaxSize()) { if (enableSearch && FeatureFlags.isEnabled("enable_new_search")) { Search(onSearchBarClick) - - Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp)) } else { if (enableSearch) { Box( @@ -748,37 +746,54 @@ fun PromptPermissionsIfNecessary() { @OptIn(ExperimentalMaterial3Api::class) @Composable fun Search( - onSearchBarClick: () -> Unit // Callback for navigating to SearchView + onSearchBarClick: () -> Unit, // Callback for navigating to SearchView + backgroundColor: Color = MaterialTheme.colorScheme.background // Default background color ) { // Prevent multiple taps var isNavigating by remember { mutableStateOf(false) } - // Outer Box to handle clicks Box( modifier = 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)) - } + .padding(top = 8.dp)) { + Box( + modifier = + Modifier.fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 16.dp) + .height(56.dp) + .clip(MaterialTheme.shapes.extraLarge) // Rounded corners for search bar + .background(backgroundColor) // Search bar background + .clickable(enabled = !isNavigating) { // Intercept taps + isNavigating = true + onSearchBarClick() + } + .padding(horizontal = 16.dp) // Internal padding + ) { + Row( + verticalAlignment = Alignment.CenterVertically, // Ensure icon aligns with text + modifier = Modifier.fillMaxSize()) { + // Leading Icon + Icon( + imageVector = Icons.Outlined.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = + Modifier.padding(start = 0.dp) // Optional start padding for alignment + ) + Spacer(modifier = Modifier.width(4.dp)) + + // Placeholder Text + Text( + text = stringResource(R.string.search_ellipsis), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) // Ensure text takes up remaining space + ) + } + } } } 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 index f75aabb..399c7c0 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SearchView.kt @@ -3,8 +3,15 @@ package com.tailscale.ipn.ui.view +import android.app.Activity +import android.os.Build +import android.util.Log +import android.window.OnBackInvokedCallback +import android.window.OnBackInvokedDispatcher +import androidx.annotation.RequiresApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,9 +20,9 @@ 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.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState 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 @@ -23,11 +30,11 @@ 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.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -39,110 +46,191 @@ 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.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.listItem +import com.tailscale.ipn.ui.util.Lists import com.tailscale.ipn.ui.viewModel.MainViewModel +import kotlinx.coroutines.delay +@RequiresApi(Build.VERSION_CODES.TIRAMISU) @OptIn(ExperimentalMaterial3Api::class) @Composable -fun SearchView(viewModel: MainViewModel, navController: NavController, onNavigateBack: () -> Unit) { - val searchTerm by viewModel.searchTerm.collectAsState() - val filteredPeers by viewModel.peers.collectAsState() +fun SearchView( + viewModel: MainViewModel, + navController: NavController, + onNavigateBack: () -> Unit, + autoFocus: Boolean // Pass true if coming from the main view, false otherwise. +) { + // Use TextFieldValue to preserve text and cursor position. + var searchFieldValue by + rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) } + val searchTerm = searchFieldValue.text + + val filteredPeers by viewModel.searchViewPeers.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) } + val context = LocalContext.current as Activity + val listState = rememberLazyListState() + + val noResultsBackground = + if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.surface // color for dark mode + } else { + MaterialTheme.colorScheme.surfaceContainer // color for light mode + } - LaunchedEffect(Unit) { - focusRequester.requestFocus() - keyboardController?.show() + val callback = OnBackInvokedCallback { + focusManager.clearFocus(force = true) + keyboardController?.hide() + onNavigateBack() + viewModel.updateSearchTerm("") } - 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 = { + DisposableEffect(Unit) { + val dispatcher = context.onBackInvokedDispatcher + dispatcher?.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback) + onDispose { dispatcher?.unregisterOnBackInvokedCallback(callback) } + } + + LaunchedEffect(searchTerm, filteredPeers) { + if (searchTerm.isEmpty() && filteredPeers.isNotEmpty()) { + delay(100) // Give Compose time to update list + listState.scrollToItem(0) + } + } + + // Use the autoFocus parameter to decide if we request focus when entering. + LaunchedEffect(autoFocus) { + if (autoFocus) { + delay(300) // Delay to ensure UI is fully composed + focusRequester.requestFocus() + keyboardController?.show() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)) { + SearchBar( + modifier = Modifier.fillMaxWidth(), + query = searchTerm, + onQueryChange = { newQuery -> + // Create a new TextFieldValue with updated text and set cursor to the end. + searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) + viewModel.updateSearchTerm(newQuery) + expanded = true + }, + onSearch = { newQuery -> + searchFieldValue = TextFieldValue(newQuery, selection = TextRange(newQuery.length)) + viewModel.updateSearchTerm(newQuery) + focusManager.clearFocus() + keyboardController?.hide() + }, + placeholder = { Text(text = stringResource(R.string.search)) }, + leadingIcon = { + IconButton( + onClick = { + focusManager.clearFocus() + onNavigateBack() + viewModel.updateSearchTerm("") + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { IconButton( onClick = { + searchFieldValue = TextFieldValue("", selection = TextRange(0)) + viewModel.updateSearchTerm("") focusManager.clearFocus() - onNavigateBack() + keyboardController?.hide() }) { Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurfaceVariant) + Icons.Default.Clear, + contentDescription = stringResource(R.string.clear_search)) } - }, - 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()) { + } + }, + active = expanded, + onActiveChange = { expanded = it }, + content = { + LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) { + if (filteredPeers.isEmpty()) { + // When there are no filtered peers, show a "No results" message. + item { + Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Lists.LargeTitle( + stringResource(id = R.string.no_results), + bottomPadding = 8.dp, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Light, + backgroundColor = noResultsBackground, + fontColor = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } else { + var firstGroup = true 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" + if (!firstGroup) { + item { Lists.ItemDivider() } + } + firstGroup = false - 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) + val userName = peerSet.user?.DisplayName ?: "Unknown User" + peerSet.peers.forEachIndexed { index, peer -> + if (index > 0) { + item(key = "divider_${peer.StableID}") { Lists.ItemDivider() } + } + item(key = "peer_${peer.StableID}") { + ListItem( + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { + Column { + Row(verticalAlignment = Alignment.CenterVertically) { + val onlineColor = peer.connectedColor(netmap) + Box( + modifier = + Modifier.size(10.dp) + .background(onlineColor, RoundedCornerShape(50))) + Spacer(modifier = Modifier.size(8.dp)) + Text(peer.displayName ?: "Unknown Device") + } + } + }, + supportingContent = { + Column { + Text(userName) + Text(peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP") } - Text(ipAddress) - } - }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = - Modifier.clickable { - navController.navigate("peerDetails/${peer.StableID}") - } - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp)) + }, + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 0.dp) + .clickable { + viewModel.disableSearchAutoFocus() + navController.navigate("peerDetails/${peer.StableID}") + }) + } } } } - }) - } + } + }) + } + } } diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt index ffab356..4f6de17 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/SubnetRoutingView.kt @@ -36,99 +36,96 @@ import com.tailscale.ipn.ui.viewModel.SubnetRoutingViewModel @Composable fun SubnetRoutingView(backToSettings: BackNavigation, model: SubnetRoutingViewModel = viewModel()) { - val subnetRoutes by model.advertisedRoutes.collectAsState() - val uriHandler = LocalUriHandler.current - val isPresentingDialog by model.isPresentingDialog.collectAsState() - val useSubnets by model.routeAll.collectAsState() - val currentError by model.currentError.collectAsState() + val subnetRoutes by model.advertisedRoutes.collectAsState() + val uriHandler = LocalUriHandler.current + val isPresentingDialog by model.isPresentingDialog.collectAsState() + val useSubnets by model.routeAll.collectAsState() + val currentError by model.currentError.collectAsState() - Scaffold(topBar = { - Header(R.string.subnet_routes, onBack = backToSettings, actions = { - IconButton(onClick = { - uriHandler.openUri(SUBNET_ROUTERS_KB_URL) - }) { + Scaffold( + topBar = { + Header( + R.string.subnet_routes, + onBack = backToSettings, + actions = { + IconButton(onClick = { uriHandler.openUri(SUBNET_ROUTERS_KB_URL) }) { Icon( - painter = painterResource(R.drawable.info), contentDescription = stringResource( - R.string.open_kb_article - ) - ) - } - }) - }) { innerPadding -> + painter = painterResource(R.drawable.info), + contentDescription = stringResource(R.string.open_kb_article)) + } + }) + }) { innerPadding -> LoadingIndicator.Wrap { - LazyColumn(modifier = Modifier.padding(innerPadding)) { - currentError?.let { - item("error") { - ErrorDialog(title = R.string.failed_to_save, message = it, onDismiss = { - model.onErrorDismissed() - }) - } - } - item("subnetsToggle") { - Setting.Switch(R.string.use_tailscale_subnets, isOn = useSubnets, onToggle = { - LoadingIndicator.start() - model.toggleUseSubnets { LoadingIndicator.stop() } - }) - } - item("subtitle") { - ListItem(headlineContent = { - Text( - stringResource(R.string.use_tailscale_subnets_subtitle), - modifier = Modifier.padding(bottom = 8.dp) - ) - }) - } - item("divider0") { - Lists.SectionDivider() - } - item(key = "header") { - Lists.MutedHeader(stringResource(R.string.advertised_routes)) - ListItem(headlineContent = { - Text( - stringResource(R.string.run_as_subnet_router_header), - modifier = Modifier.padding(vertical = 8.dp) - ) - }) - } - - itemsWithDividers(subnetRoutes, key = { it }) { - SubnetRouteRowView(route = it, onEdit = { - model.startEditingRoute(it) - }, onDelete = { - model.deleteRoute(it) - }, modifier = Modifier.animateItem()) - } + LazyColumn(modifier = Modifier.padding(innerPadding)) { + currentError?.let { + item("error") { + ErrorDialog( + title = R.string.failed_to_save, + message = it, + onDismiss = { model.onErrorDismissed() }) + } + } + item("subnetsToggle") { + Setting.Switch( + R.string.use_tailscale_subnets, + isOn = useSubnets, + onToggle = { + LoadingIndicator.start() + model.toggleUseSubnets { LoadingIndicator.stop() } + }) + } + item("subtitle") { + ListItem( + headlineContent = { + Text( + stringResource(R.string.use_tailscale_subnets_subtitle), + modifier = Modifier.padding(bottom = 8.dp)) + }) + } + item("divider0") { Lists.SectionDivider() } + item(key = "header") { + Lists.MutedHeader(stringResource(R.string.advertised_routes)) + ListItem( + headlineContent = { + Text( + stringResource(R.string.run_as_subnet_router_header), + modifier = Modifier.padding(vertical = 8.dp)) + }) + } - item("addNewRoute") { - Lists.ItemDivider() - ListItem(headlineContent = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Outlined.Add, contentDescription = null) - Text(stringResource(R.string.add_new_route)) - } - }, modifier = Modifier.clickable { model.startEditingRoute("") }) - } + itemsWithDividers(subnetRoutes, key = { it }) { + SubnetRouteRowView( + route = it, + onEdit = { model.startEditingRoute(it) }, + onDelete = { model.deleteRoute(it) }, + modifier = Modifier.animateItem()) } - } - } - if (isPresentingDialog) { - Dialog(onDismissRequest = { - model.isPresentingDialog.set(false) - }) { - Card { - EditSubnetRouteDialogView(valueFlow = model.dialogTextFieldValue, - isValueValidFlow = model.isTextFieldValueValid, - onValueChange = { - model.dialogTextFieldValue.set(it) - }, - onCommit = { - model.doneEditingRoute(newValue = it) - }, - onCancel = { - model.stopEditingRoute() - }) + item("addNewRoute") { + Lists.ItemDivider() + ListItem( + headlineContent = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.Add, contentDescription = null) + Text(stringResource(R.string.add_new_route)) + } + }, + modifier = Modifier.clickable { model.startEditingRoute("") }) } + } } + } + + if (isPresentingDialog) { + Dialog(onDismissRequest = { model.isPresentingDialog.set(false) }) { + Card { + EditSubnetRouteDialogView( + valueFlow = model.dialogTextFieldValue, + isValueValidFlow = model.isTextFieldValueValid, + onValueChange = { model.dialogTextFieldValue.set(it) }, + onCommit = { model.doneEditingRoute(newValue = it) }, + onCancel = { model.stopEditingRoute() }) + } } -} \ No newline at end of file + } +} diff --git a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt index e8e486f..0c2a3dc 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/view/UserView.kt @@ -52,7 +52,7 @@ fun UserView( ListItem( modifier = modifier, colors = colors, - leadingContent = { Avatar(profile = profile, size = 36, isFocusable = false) }, + leadingContent = { Avatar(profile = profile, size = 36) }, headlineContent = { AutoResizingText( text = profile.UserProfile.LoginName, diff --git a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt index b139b46..8ff5353 100644 --- a/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt +++ b/android/src/main/java/com/tailscale/ipn/ui/viewModel/MainViewModel.kt @@ -6,6 +6,9 @@ package com.tailscale.ipn.ui.viewModel import android.content.Intent import android.net.VpnService import androidx.activity.result.ActivityResultLauncher +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.lifecycle.ViewModel @@ -44,7 +47,6 @@ class MainViewModelFactory(private val vpnViewModel: VpnViewModel) : ViewModelPr @OptIn(FlowPreview::class) class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { - // The user readable state of the system val stateRes: StateFlow = MutableStateFlow(userStringRes(State.NoState, State.NoState, true)) @@ -63,6 +65,10 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { private val _peers = MutableStateFlow>(emptyList()) val peers: StateFlow> = _peers + // The list of peers + private val _searchViewPeers = MutableStateFlow>(emptyList()) + val searchViewPeers: StateFlow> = _searchViewPeers + // The current state of the IPN for determining view visibility val ipnState = Notifier.state @@ -70,6 +76,9 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { private val _searchTerm = MutableStateFlow("") val searchTerm: StateFlow = _searchTerm + var autoFocusSearch by mutableStateOf(true) + private set + // True if we should render the key expiry bannder val showExpiry: StateFlow = MutableStateFlow(false) @@ -142,7 +151,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { searchJob = launch(Dispatchers.Default) { val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) - _peers.value = filteredPeers + _searchViewPeers.value = filteredPeers } } } @@ -154,7 +163,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { launch(Dispatchers.Default) { peerCategorizer.regenerateGroupedPeers(netmap) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) - _peers.value = filteredPeers + _peers.value = peerCategorizer.peerSets + _searchViewPeers.value = filteredPeers } if (netmap.SelfNode.keyDoesNotExpire) { @@ -221,6 +231,14 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { this.searchTerm.set(searchTerm) } + fun enableSearchAutoFocus() { + autoFocusSearch = true + } + + fun disableSearchAutoFocus() { + autoFocusSearch = false + } + fun setVpnPermissionLauncher(launcher: ActivityResultLauncher) { // No intent means we're already authorized vpnPermissionLauncher = launcher