From cac58de6acd98e7b7d6595d51fd0b3f641aed6e7 Mon Sep 17 00:00:00 2001 From: kari-ts Date: Fri, 31 Jan 2025 10:03:36 -0800 Subject: [PATCH] android: refine search -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/src/main/AndroidManifest.xml | 1 + .../src/main/java/com/tailscale/ipn/App.kt | 4 +- .../java/com/tailscale/ipn/MainActivity.kt | 29 ++- .../java/com/tailscale/ipn/ui/util/Lists.kt | 3 +- .../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 | 157 +++++++++------- .../ipn/ui/view/SubnetRoutingView.kt | 171 +++++++++--------- .../com/tailscale/ipn/ui/view/UserView.kt | 2 +- .../ipn/ui/viewModel/MainViewModel.kt | 10 +- 11 files changed, 256 insertions(+), 194 deletions(-) diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index b5776a6c7c..92cb0dea41 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 +150,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) @@ -186,7 +203,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") }, 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 4da762d086..eabae84c65 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 @@ -34,7 +34,8 @@ object Lists { @Composable fun ItemDivider() { - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, modifier = Modifier.fillMaxWidth()) } @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 54adf27480..1d59d913d3 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 36b75963f3..c79d5bf33a 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 538e67e486..b3d94f7063 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 f75aabb51a..fa9df1df8d 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,6 +3,11 @@ package com.tailscale.ipn.ui.view +import android.app.Activity +import android.os.Build +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.layout.Box @@ -13,9 +18,8 @@ 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.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 +27,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 +43,131 @@ 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.unit.dp import androidx.navigation.NavController import com.tailscale.ipn.R +import com.tailscale.ipn.ui.theme.listItem import com.tailscale.ipn.ui.viewModel.MainViewModel +@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() + 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 - 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) { + // Register with a priority high enough to intercept before the system does + val dispatcher = context.onBackInvokedDispatcher + dispatcher?.registerOnBackInvokedCallback(OnBackInvokedDispatcher.PRIORITY_DEFAULT, callback) + + onDispose { dispatcher?.unregisterOnBackInvokedCallback(callback) } + } + + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)) { + LaunchedEffect(Unit) { + 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() + viewModel.updateSearchTerm("") + }) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.search), + tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + }, + trailingIcon = { + if (searchTerm.isNotEmpty()) { IconButton( onClick = { + viewModel.updateSearchTerm("") focusManager.clearFocus() - onNavigateBack() + keyboardController?.hide() }) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.search), - tint = MaterialTheme.colorScheme.onSurfaceVariant) + Icon(Icons.Default.Clear, 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()) { - 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" - + } + }, + active = expanded, + onActiveChange = { expanded = it }, + content = { + LazyColumn(Modifier.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" + item(key = "peer_${peer.StableID}") { ListItem( - headlineContent = { Text(userName) }, - supportingContent = { + colors = MaterialTheme.colorScheme.listItem, + headlineContent = { Column { Row(verticalAlignment = Alignment.CenterVertically) { val onlineColor = peer.connectedColor(netmap) Box( modifier = Modifier.size(10.dp) - .background(onlineColor, shape = RoundedCornerShape(50))) + .background(onlineColor, RoundedCornerShape(50))) Spacer(modifier = Modifier.size(8.dp)) Text(deviceName) } + } + }, + supportingContent = { + Column { + Text(userName) Text(ipAddress) } }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), modifier = - Modifier.clickable { + Modifier.fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 4.dp) + .clickable { navController.navigate("peerDetails/${peer.StableID}") - } - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp)) + }) } } } - }) - } + } + }) + } + } } 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 ffab356e77..4f6de178bf 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 e8e486fa7a..0c2a3dc49b 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 b139b46098..cd4af6b0af 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 @@ -25,8 +25,10 @@ import com.tailscale.ipn.ui.util.set import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch @@ -44,7 +46,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 +64,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 @@ -142,7 +147,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { searchJob = launch(Dispatchers.Default) { val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) - _peers.value = filteredPeers + _searchViewPeers.value = filteredPeers } } } @@ -155,6 +160,7 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() { peerCategorizer.regenerateGroupedPeers(netmap) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) _peers.value = filteredPeers + _searchViewPeers.value = filteredPeers } if (netmap.SelfNode.keyDoesNotExpire) {