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) =
|
fun toggleCollapsed(channelId: Int) =
|
||||||
_collapsed.update { if (channelId in it) it - channelId else it + channelId }
|
_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()
|
val canMoveClients: Boolean get() = session.canMoveClients()
|
||||||
|
|
||||||
fun moveClient(clientId: Int, channelId: Int) {
|
fun moveClient(clientId: Int, channelId: Int) {
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ data class ServerTree(
|
|||||||
return found
|
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? {
|
private fun find(items: List<ChannelItem>, test: (ChannelItem) -> Boolean): ChannelItem? {
|
||||||
for (c in items) {
|
for (c in items) {
|
||||||
if (test(c)) return c
|
if (test(c)) return c
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.ts3client.android.ui
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
@@ -24,7 +28,12 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ProvideTextStyle
|
import androidx.compose.material3.ProvideTextStyle
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.rotate
|
import androidx.compose.ui.draw.rotate
|
||||||
@@ -39,6 +48,8 @@ import com.ts3client.android.ServerTree
|
|||||||
import com.ts3client.contacts.ContactType
|
import com.ts3client.contacts.ContactType
|
||||||
import com.ts3client.gfx.IconKeys
|
import com.ts3client.gfx.IconKeys
|
||||||
import com.ts3client.net.ClientStatus
|
import com.ts3client.net.ClientStatus
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
/** One line of the tree as the list shows it. */
|
/** One line of the tree as the list shows it. */
|
||||||
private sealed interface TreeRow {
|
private sealed interface TreeRow {
|
||||||
@@ -79,9 +90,30 @@ fun ChannelTree(
|
|||||||
onClient: (ClientItem) -> Unit,
|
onClient: (ClientItem) -> Unit,
|
||||||
onServer: () -> Unit,
|
onServer: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
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) }
|
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") {
|
item(key = "server") {
|
||||||
RowLayout(depth = 0, onClick = onServer) {
|
RowLayout(depth = 0, onClick = onServer) {
|
||||||
PackIcon("SERVER_GREEN", null, size = ICON)
|
PackIcon("SERVER_GREEN", null, size = ICON)
|
||||||
@@ -93,7 +125,7 @@ fun ChannelTree(
|
|||||||
items(rows, key = { it.key }) { row ->
|
items(rows, key = { it.key }) { row ->
|
||||||
when (row) {
|
when (row) {
|
||||||
is TreeRow.Channel -> ChannelRow(row, onToggle, onChannel)
|
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 ICON = 16.dp
|
||||||
private val INDENT = 14.dp
|
private val INDENT = 14.dp
|
||||||
private val ROW = 26.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
|
@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(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.background(background)
|
||||||
.height(ROW)
|
.height(ROW)
|
||||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
.padding(start = 4.dp + INDENT * depth, end = 8.dp),
|
.padding(start = 4.dp + INDENT * depth, end = 8.dp),
|
||||||
@@ -170,9 +210,14 @@ private fun SpacerLabel(align: Char, caption: String, modifier: Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
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))
|
Spacer(Modifier.width(ICON + 4.dp))
|
||||||
StatusIcon(client.status)
|
StatusIcon(client.status)
|
||||||
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
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) }
|
var chatKey by rememberSaveable(stateSaver = ChatKeySaver) { mutableStateOf<ChatKey>(ChatKey.Channel) }
|
||||||
val conversation = chats[chatKey] ?: chats.channel
|
val conversation = chats[chatKey] ?: chats.channel
|
||||||
var clientSheet by remember { mutableStateOf<Int?>(null) }
|
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 channelSheet by remember { mutableStateOf<Int?>(null) }
|
||||||
var serverSheet by remember { mutableStateOf(false) }
|
var serverSheet by remember { mutableStateOf(false) }
|
||||||
/** The client to move, while a channel for them is being picked. */
|
/** The client to move, while a channel for them is being picked. */
|
||||||
@@ -138,7 +141,7 @@ fun ServerScreen(
|
|||||||
val ref = TsLink.parse(href)
|
val ref = TsLink.parse(href)
|
||||||
when {
|
when {
|
||||||
ref == null -> runCatching { uriHandler.openUri(href) }
|
ref == null -> runCatching { uriHandler.openUri(href) }
|
||||||
ref.client -> clientSheet = ref.id
|
ref.client -> { clientSheet = ref.id; clientSheetFromTree = false }
|
||||||
else -> channelSheet = ref.id
|
else -> channelSheet = ref.id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,9 +199,11 @@ fun ServerScreen(
|
|||||||
else -> channelSheet = channel.id
|
else -> channelSheet = channel.id
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onClient = { clientSheet = it.id },
|
onClient = { clientSheet = it.id; clientSheetFromTree = true },
|
||||||
onServer = { serverSheet = true },
|
onServer = { serverSheet = true },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
|
reveal = reveal,
|
||||||
|
onRevealed = { reveal = null },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ChatPane(
|
ChatPane(
|
||||||
@@ -243,6 +248,12 @@ fun ServerScreen(
|
|||||||
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
|
onMoveToOwnChannel = if (server.canMoveClients && tree.channel(state.channelId)?.clients?.none { it.id == id } == true) {
|
||||||
{ server.moveClient(id, state.channelId); clientSheet = null }
|
{ server.moveClient(id, state.channelId); clientSheet = null }
|
||||||
} else 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)?,
|
onMove: (() -> Unit)?,
|
||||||
/** Null when the client cannot be moved, or is in our channel already. */
|
/** Null when the client cannot be moved, or is in our channel already. */
|
||||||
onMoveToOwnChannel: (() -> Unit)?,
|
onMoveToOwnChannel: (() -> Unit)?,
|
||||||
|
/** Null when the sheet was opened from the tree. */
|
||||||
|
onFindInTree: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
var poking by remember { mutableStateOf(false) }
|
var poking by remember { mutableStateOf(false) }
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
@@ -82,6 +84,7 @@ fun ClientSheet(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
|
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) {
|
if (client.self) {
|
||||||
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
|
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user