android: move node search to background and fix avatar padding (#574)

android: use background search and fix avatar padding

fixes tailscale/corp#24847
fixes tailsacle/corp#24848

Search jobs are moved to the default dispatcher so they
do not block the UI thread.

The avatar boxing is now used only conditionally on AndroidTV.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
pull/576/head
Jonathan Nobels 1 year ago committed by GitHub
parent fda3820582
commit f35b3f9274
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -163,7 +163,7 @@ class App : UninitializedApp(), libtailscale.AppContext, ViewModelStoreOwner {
NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns) NetworkChangeCallback.monitorDnsChanges(connectivityManager, dns)
initViewModels() initViewModels()
applicationScope.launch { applicationScope.launch {
Notifier.state.collect { state -> Notifier.state.collect { _ ->
combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled -> combine(Notifier.state, MDMSettings.forceEnabled.flow) { state, forceEnabled ->
Pair(state, forceEnabled) Pair(state, forceEnabled)
} }

@ -0,0 +1,18 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.util
import androidx.compose.ui.Modifier
/// Applies different modifiers to the receiver based on a condition.
inline fun Modifier.conditional(
condition: Boolean,
ifTrue: Modifier.() -> Modifier,
ifFalse: Modifier.() -> Modifier = { this },
): Modifier =
if (condition) {
then(ifTrue(Modifier))
} else {
then(ifFalse(Modifier))
}

@ -31,6 +31,8 @@ import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
import com.tailscale.ipn.ui.util.AndroidTVUtil
import com.tailscale.ipn.ui.util.conditional
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@Composable @Composable
@ -43,53 +45,49 @@ fun Avatar(
var isFocused = remember { mutableStateOf(false) } var isFocused = remember { mutableStateOf(false) }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
// Outer Box for the larger focusable and clickable area // Outer Box for the larger focusable and clickable area
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier =
.padding(4.dp) Modifier.conditional(AndroidTVUtil.isAndroidTV(), { padding(4.dp) })
.size((size * 1.5f).dp) // Focusable area is larger than the avatar .conditional(
.clip(CircleShape) // Ensure both the focus and click area are circular AndroidTVUtil.isAndroidTV(),
.background( {
if (isFocused.value) MaterialTheme.colorScheme.surface size((size * 1.5f).dp) // Focusable area is larger than the avatar
else Color.Transparent, })
) .clip(CircleShape) // Ensure both the focus and click area are circular
.onFocusChanged { focusState -> .background(
isFocused.value = focusState.isFocused if (isFocused.value) MaterialTheme.colorScheme.surface else Color.Transparent,
} )
.focusable() // Make this outer Box focusable (after onFocusChanged) .onFocusChanged { focusState -> isFocused.value = focusState.isFocused }
.clickable( .focusable() // Make this outer Box focusable (after onFocusChanged)
interactionSource = remember { MutableInteractionSource() }, .clickable(
indication = ripple(bounded = true), // Apply ripple effect inside circular bounds interactionSource = remember { MutableInteractionSource() },
onClick = { indication = ripple(bounded = true), // Apply ripple effect inside circular bounds
action?.invoke() onClick = {
action?.invoke()
focusManager.clearFocus() // Clear focus after clicking the avatar focusManager.clearFocus() // Clear focus after clicking the avatar
} })) {
)
) {
// Inner Box to hold the avatar content (Icon or AsyncImage) // Inner Box to hold the avatar content (Icon or AsyncImage)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
modifier = Modifier modifier = Modifier.size(size.dp).clip(CircleShape)) {
.size(size.dp) // Always display the default icon as a background layer
.clip(CircleShape) Icon(
) { imageVector = Icons.Default.Person,
// Always display the default icon as a background layer contentDescription = stringResource(R.string.settings_title),
Icon( modifier =
imageVector = Icons.Default.Person, Modifier.conditional(AndroidTVUtil.isAndroidTV(), { size((size * 0.8f).dp) })
contentDescription = stringResource(R.string.settings_title), .clip(CircleShape) // Icon size slightly smaller than the Box
modifier = )
Modifier.size((size * 0.8f).dp)
.clip(CircleShape) // Icon size slightly smaller than the Box
)
// Overlay the profile picture if available // Overlay the profile picture if available
profile?.UserProfile?.ProfilePicURL?.let { url -> profile?.UserProfile?.ProfilePicURL?.let { url ->
AsyncImage( AsyncImage(
model = url, model = url,
modifier = Modifier.size(size.dp).clip(CircleShape), modifier = Modifier.size(size.dp).clip(CircleShape),
contentDescription = null) contentDescription = null)
} }
} }
} }
} }

@ -23,7 +23,9 @@ import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.TimeUtil import com.tailscale.ipn.ui.util.TimeUtil
import com.tailscale.ipn.ui.util.set import com.tailscale.ipn.ui.util.set
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -77,6 +79,8 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive val isVpnActive: StateFlow<Boolean> = vpnViewModel.vpnActive
var searchJob: Job? = null
// Icon displayed in the button to present the health view // Icon displayed in the button to present the health view
val healthIcon: StateFlow<Int?> = MutableStateFlow(null) val healthIcon: StateFlow<Int?> = MutableStateFlow(null)
@ -130,18 +134,25 @@ class MainViewModel(private val vpnViewModel: VpnViewModel) : IpnViewModel() {
viewModelScope.launch { viewModelScope.launch {
_searchTerm.debounce(250L).collect { term -> _searchTerm.debounce(250L).collect { term ->
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term) // run the search as a background task
_peers.value = filteredPeers searchJob?.cancel()
searchJob =
launch(Dispatchers.Default) {
val filteredPeers = peerCategorizer.groupedAndFilteredPeers(term)
_peers.value = filteredPeers
}
} }
} }
viewModelScope.launch { viewModelScope.launch {
Notifier.netmap.collect { it -> Notifier.netmap.collect { it ->
it?.let { netmap -> it?.let { netmap ->
peerCategorizer.regenerateGroupedPeers(netmap) searchJob?.cancel()
launch(Dispatchers.Default) {
// Immediately update _peers with the full peer list peerCategorizer.regenerateGroupedPeers(netmap)
_peers.value = peerCategorizer.groupedAndFilteredPeers(searchTerm.value) val filteredPeers = peerCategorizer.groupedAndFilteredPeers(searchTerm.value)
_peers.value = filteredPeers
}
if (netmap.SelfNode.keyDoesNotExpire) { if (netmap.SelfNode.keyDoesNotExpire) {
showExpiry.set(false) showExpiry.set(false)

Loading…
Cancel
Save