Find a client in the channel tree from their sheet on Android
Offered, as on desktop, when the sheet was opened from outside the tree (a name in the chat): expands the client's channels, scrolls to them and flashes their row amber. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,8 @@ class ServerController internal constructor(
|
||||
fun toggleCollapsed(channelId: Int) =
|
||||
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
|
||||
|
||||
fun expand(channelIds: Collection<Int>) = _collapsed.update { it - channelIds.toSet() }
|
||||
|
||||
val canMoveClients: Boolean get() = session.canMoveClients()
|
||||
|
||||
fun moveClient(clientId: Int, channelId: Int) {
|
||||
|
||||
@@ -30,6 +30,18 @@ data class ServerTree(
|
||||
return found
|
||||
}
|
||||
|
||||
/** The channels from the root down to the one [clientId] is in; null when they are not in the tree. */
|
||||
fun pathTo(clientId: Int): List<Int>? {
|
||||
fun walk(items: List<ChannelItem>): List<Int>? {
|
||||
for (c in items) {
|
||||
if (c.clients.any { it.id == clientId }) return listOf(c.id)
|
||||
walk(c.children)?.let { return listOf(c.id) + it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(channels)
|
||||
}
|
||||
|
||||
private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? {
|
||||
for (c in items) {
|
||||
if (test(c)) return c
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.ts3client.android.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -16,6 +19,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -24,7 +28,12 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
@@ -39,6 +48,8 @@ import com.ts3client.android.ServerTree
|
||||
import com.ts3client.contacts.ContactType
|
||||
import com.ts3client.gfx.IconKeys
|
||||
import com.ts3client.net.ClientStatus
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
/** One line of the tree as the list shows it. */
|
||||
private sealed interface TreeRow {
|
||||
@@ -79,9 +90,30 @@ fun ChannelTree(
|
||||
onClient: (ClientItem) -> Unit,
|
||||
onServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
/** A client to scroll to and flash, once their channels are expanded. */
|
||||
reveal: Int? = null,
|
||||
onRevealed: () -> Unit = {},
|
||||
) {
|
||||
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
|
||||
LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) {
|
||||
val listState = rememberLazyListState()
|
||||
var flashing by remember { mutableStateOf<Int?>(null) }
|
||||
LaunchedEffect(reveal, rows) {
|
||||
val id = reveal ?: return@LaunchedEffect
|
||||
val index = rows.indexOfFirst { it is TreeRow.Client && it.client.id == id }
|
||||
if (index < 0) return@LaunchedEffect
|
||||
// Index 0 is the server's row. The client ends up in the middle of the list.
|
||||
val middle = snapshotFlow { listState.layoutInfo.viewportSize.height }.first { it > 0 } / 2
|
||||
listState.animateScrollToItem(index + 1, -middle)
|
||||
flashing = id
|
||||
onRevealed()
|
||||
}
|
||||
LaunchedEffect(flashing) {
|
||||
if (flashing != null) {
|
||||
delay(1500)
|
||||
flashing = null
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues(vertical = 4.dp)) {
|
||||
item(key = "server") {
|
||||
RowLayout(depth = 0, onClick = onServer) {
|
||||
PackIcon("SERVER_GREEN", null, size = ICON)
|
||||
@@ -93,7 +125,7 @@ fun ChannelTree(
|
||||
items(rows, key = { it.key }) { row ->
|
||||
when (row) {
|
||||
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
|
||||
is TreeRow.Client -> ClientRow(row, tree.countryFlags, onClient)
|
||||
is TreeRow.Client -> ClientRow(row, tree.countryFlags, row.client.id == flashing, onClient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,12 +134,20 @@ fun ChannelTree(
|
||||
private val ICON = 16.dp
|
||||
private val INDENT = 14.dp
|
||||
private val ROW = 26.dp
|
||||
/** Amber, see-through so the row's text keeps its own colour and contrast. */
|
||||
private val FLASH = Color(0x80FFB300)
|
||||
|
||||
@Composable
|
||||
private fun RowLayout(depth: Int, onClick: (() -> Unit)?, content: @Composable RowScope.() -> Unit) {
|
||||
private fun RowLayout(
|
||||
depth: Int,
|
||||
onClick: (() -> Unit)?,
|
||||
background: Color = Color.Transparent,
|
||||
content: @Composable RowScope.() -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(background)
|
||||
.height(ROW)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(start = 4.dp + INDENT * depth, end = 8.dp),
|
||||
@@ -170,9 +210,14 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, onClient: (ClientItem) -> Unit) {
|
||||
private fun ClientRow(row: TreeRow.Client, countryFlags: Boolean, flashing: Boolean, onClient: (ClientItem) -> Unit) {
|
||||
val client = row.client
|
||||
RowLayout(row.depth, onClick = { onClient(client) }) {
|
||||
val background by animateColorAsState(
|
||||
if (flashing) FLASH else FLASH.copy(alpha = 0f),
|
||||
tween(if (flashing) 150 else 600),
|
||||
label = "flash",
|
||||
)
|
||||
RowLayout(row.depth, onClick = { onClient(client) }, background = background) {
|
||||
Spacer(Modifier.width(ICON + 4.dp))
|
||||
StatusIcon(client.status)
|
||||
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
|
||||
@@ -113,6 +113,9 @@ fun ServerScreen(
|
||||
var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
|
||||
val conversation = chats[chatKey] ?: chats.channel
|
||||
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
||||
var clientSheetFromTree by remember { mutableStateOf(false) }
|
||||
/** A client to show in the tree. */
|
||||
var reveal by remember { mutableStateOf<Int?>(null) }
|
||||
var channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||
var serverSheet by remember { mutableStateOf(false) }
|
||||
/** The client to move, while a channel for them is being picked. */
|
||||
@@ -138,7 +141,7 @@ fun ServerScreen(
|
||||
val ref = TsLink.parse(href)
|
||||
when {
|
||||
ref == null -> runCatching { uriHandler.openUri(href) }
|
||||
ref.client -> clientSheet = ref.id
|
||||
ref.client -> { clientSheet = ref.id; clientSheetFromTree = false }
|
||||
else -> channelSheet = ref.id
|
||||
}
|
||||
}
|
||||
@@ -196,9 +199,11 @@ fun ServerScreen(
|
||||
else -> channelSheet = channel.id
|
||||
}
|
||||
},
|
||||
onClient = { clientSheet = it.id },
|
||||
onClient = { clientSheet = it.id; clientSheetFromTree = true },
|
||||
onServer = { serverSheet = true },
|
||||
modifier = Modifier.weight(1f),
|
||||
reveal = reveal,
|
||||
onRevealed = { reveal = null },
|
||||
)
|
||||
} else {
|
||||
ChatPane(
|
||||
@@ -243,6 +248,12 @@ fun ServerScreen(
|
||||
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
|
||||
{ server.moveClient(id, state.channelId); clientSheet = null }
|
||||
} else null,
|
||||
onFindInTree = if (clientSheetFromTree) null else ({
|
||||
clientSheet = null
|
||||
tree.pathTo(id)?.let { server.expand(it) }
|
||||
showChat = false
|
||||
reveal = id
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ fun ClientSheet(
|
||||
onMove: (() -> Unit)?,
|
||||
/** Null when the client cannot be moved, or is in our channel already. */
|
||||
onMoveToOwnChannel: (() -> Unit)?,
|
||||
/** Null when the sheet was opened from the tree. */
|
||||
onFindInTree: (() -> Unit)?,
|
||||
) {
|
||||
var poking by remember { mutableStateOf(false) }
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
@@ -82,6 +84,7 @@ fun ClientSheet(
|
||||
})
|
||||
}
|
||||
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
||||
onFindInTree?.let { Action("CHANNEL_SWITCH", "Find client in channel tree", onClick = it) }
|
||||
if (client.self) {
|
||||
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user