Show server, channel and client details on Android, and the server's on the desktop

Core's Details says what the info box tells, as TeamSpeak's info frame does:
labelled facts, then a BBCode description. The desktop info panel now lays it
out instead of building its own, and selecting the server row shows the
server: address, version, clients, channels, creation date and its welcome
and host messages, from the virtualserver_* properties kept since initserver.

On Android the channel and client sheets show the same details, asking the
server for a missing channel description or a client's variables; tapping
the server row opens a server sheet. Descriptions render as TeamSpeak's do:
BBCode now knows [hr], [left]/[right]/[justify], numbered and lettered lists
and tables, which Android's HTML parser approximates — rules as dividers,
table rows as lines, list items numbered, font sizes as big/small. Links are
blue whatever the wallpaper.

Push-to-talk in the bottom bar follows whichever server holds the microphone,
and a server without it keeps its button to take it in a fixed slot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 21:52:28 +00:00
parent 9b1aa45611
commit cd02e4b2d8
17 changed files with 599 additions and 84 deletions

View File

@@ -2,6 +2,7 @@ package com.ts3client.android
import com.ts3client.contacts.ContactType
import com.ts3client.net.ChannelNode
import com.ts3client.net.Details
import com.ts3client.net.ConnectionListener.ChatScope
import com.ts3client.session.ServerSession
import com.ts3client.text.BBCode
@@ -67,6 +68,10 @@ class ServerController internal constructor(
if (value is ChatKey.Private) app.notifier.clear(id, value.clientId)
}
/** Counts the answers to info requests, for the details on screen to be read again. */
private val _infoUpdates = MutableStateFlow(0)
val infoUpdates: StateFlow<Int> = _infoUpdates
/** A private chat a notification asked to show, until the UI took it up. */
private val _chatRequest = MutableStateFlow<ChatKey.Private?>(null)
val chatRequest: StateFlow<ChatKey.Private?> = _chatRequest
@@ -149,6 +154,27 @@ class ServerController internal constructor(
conn.model.getClient(clientId)?.let { conn.setClientVolumeModifier(it, decibels) }
}
// ---- details ----
fun serverDetails(): Details? =
if (session.isConnected) Details.server(conn.model, "${conn.serverHost}:${conn.serverPort}") else null
fun channelDetails(channelId: Int): Details? = conn.model.getChannel(channelId)?.let(Details::channel)
fun clientDetails(clientId: Int): Details? = conn.model.getClient(clientId)?.let {
Details.client(it, conn.model, app.contactStore.find(it.uniqueId))
}
/** Asks the server for what the channel's details lack; they change once it answers. */
fun requestChannelDetails(channelId: Int) {
val channel = conn.model.getChannel(channelId) ?: return
if (session.isConnected && !channel.descriptionLoaded) conn.requestChannelInfo(channelId)
}
fun requestClientDetails(clientId: Int) {
if (session.isConnected) conn.requestClientInfo(clientId)
}
/** Files a client on the server as a contact; a new entry takes the type's defaults. */
fun addContact(clientId: Int, type: ContactType) {
val client = conn.model.getClient(clientId) ?: return
@@ -291,7 +317,7 @@ class ServerController internal constructor(
}
override fun onModelChanged() = scheduleTree(structure = true)
override fun onInfoUpdated() {}
override fun onInfoUpdated() = _infoUpdates.update { it + 1 }
override fun onIconsUpdated() {}
override fun onChat(scope: ChatScope, fromClientId: Int, fromUniqueId: String?, fromName: String, message: String) {

View File

@@ -77,12 +77,13 @@ fun ChannelTree(
onToggle: (ChannelItem) -> Unit,
onChannel: (ChannelItem) -> Unit,
onClient: (ClientItem) -> Unit,
onServer: () -> Unit,
modifier: Modifier = Modifier,
) {
val rows = remember(tree, collapsed, currentChannel) { flatten(tree, collapsed, currentChannel) }
LazyColumn(modifier.fillMaxSize(), contentPadding = PaddingValues(vertical = 4.dp)) {
item(key = "server") {
RowLayout(depth = 0, onClick = null) {
RowLayout(depth = 0, onClick = onServer) {
PackIcon("SERVER_GREEN", null, size = ICON)
Text(serverName, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
}

View File

@@ -132,7 +132,6 @@ private fun ConversationChip(
@Composable
private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Modifier) {
val listState = rememberLazyListState(initialFirstVisibleItemIndex = (lines.size - 1).coerceAtLeast(0))
val linkColor = MaterialTheme.colorScheme.primary
val link by rememberUpdatedState(onLink)
LaunchedEffect(lines.lastOrNull()?.id) {
val info = listState.layoutInfo
@@ -141,8 +140,7 @@ private fun Lines(lines: List<ChatLine>, onLink: (String) -> Unit, modifier: Mod
}
LazyColumn(modifier.fillMaxWidth(), state = listState, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) {
items(lines, key = { it.id }) { line ->
val text = remember(line.html, linkColor) { chatText(line.html, linkColor) { link(it) } }
if (text.isNotEmpty()) Text(text, Modifier.padding(vertical = 2.dp), style = MaterialTheme.typography.bodyMedium)
RichText(line.html, { link(it) }, Modifier.padding(vertical = 2.dp), MaterialTheme.typography.bodyMedium)
}
}
}

View File

@@ -1,20 +1,58 @@
package com.ts3client.android.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.fromHtml
import androidx.compose.ui.unit.dp
import com.ts3client.text.ChatHtml
/**
* The HTML of a chat line as styled text. Android's HTML parser knows no stylesheets, so
* the classes [ChatHtml] marks lines with become inline colours first.
* HTML as [BBCode][com.ts3client.text.BBCode] and [ChatHtml] write it, with its rules as dividers:
* Android's HTML parser knows none, so the text is split at them.
*/
@Composable
fun RichText(html: String, onLink: (String) -> Unit, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current) {
val link by rememberUpdatedState(onLink)
val color = linkColor()
val parts = remember(html, color) { html.split(RULE).map { chatText(it, color) { href -> link(href) } } }
Column(modifier) {
parts.forEachIndexed { i, text ->
if (i > 0) HorizontalDivider(Modifier.padding(vertical = 6.dp))
// As wide as there is room, for aligned paragraphs to have somewhere to go.
if (text.isNotEmpty()) Text(text, Modifier.fillMaxWidth(), style = style)
}
}
}
/**
* HTML without rules as styled text. Android's parser knows no stylesheets, tables,
* numbered lists, font sizes or `align`, so the classes [ChatHtml] marks lines with become
* inline colours, table rows lines of their cells, numbered items numbered lines, sizes
* `<big>`/`<small>`, and alignment the style it does know.
*/
fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): AnnotatedString {
val styled = CLASS.replace(html) { m -> CLASS_COLORS[m.groupValues[1]]?.let { "style=\"color:$it\"" } ?: "" }
val styled = sizes(numbered(CLASS.replace(html) { m ->
CLASS_COLORS[m.groupValues[1]]?.let { "style=\"color:$it\"" } ?: ""
}))
.replace(ALIGN) { m -> "style=\"text-align:${ALIGNS.getValue(m.groupValues[1])}\"" }
.replace(CELL_END, " ")
.replace(ROW_END, "<br>")
val text = AnnotatedString.fromHtml(
styled,
TextLinkStyles(SpanStyle(color = linkColor, fontWeight = FontWeight.SemiBold)),
@@ -25,7 +63,61 @@ fun chatText(html: String, linkColor: Color, onLink: (String) -> Unit): Annotate
return if (end <= start) AnnotatedString("") else text.subSequence(start, end)
}
/** Numbers the items of each `<ol>`, innermost first. */
private fun numbered(html: String): String {
var out = html
while (true) {
val list = INNER_OL.find(out) ?: return out
val letters = list.groupValues[1].isNotEmpty()
var n = 0
val items = LI.replace(list.groupValues[2]) { m ->
if (m.value.startsWith("</")) "" else {
n++
"<br>" + (if (letters) ('a' + (n - 1) % 26).toString() else n.toString()) + ". "
}
}
out = out.replaceRange(list.range, "<div>$items</div>")
}
}
/**
* Turns each font-size span into `<big>`s or `<small>` (or a plain span), closing it alike.
* Sizes are TeamSpeak's, around its 11 px text.
*/
private fun sizes(html: String): String {
val closes = ArrayDeque<String>()
return SPAN.replace(html) { m ->
if (m.value.startsWith("</")) {
closes.removeLastOrNull() ?: m.value
} else {
val px = m.groupValues[1].toIntOrNull()
val tags = when {
px == null -> listOf("span")
px >= BIGGER_PX -> listOf("big", "big")
px >= BIG_PX -> listOf("big")
px <= SMALL_PX -> listOf("small")
else -> listOf("span")
}
closes.addLast(tags.reversed().joinToString("") { "</$it>" })
if (px == null) m.value else tags.joinToString("") { "<$it>" }
}
}
}
private const val BIGGER_PX = 18
private const val BIG_PX = 13
private const val SMALL_PX = 9
private val INNER_OL = Regex("<ol( type=\"a\")?>((?:(?!<ol[ >]).)*?)</ol>", RegexOption.DOT_MATCHES_ALL)
private val LI = Regex("</?li>")
private val SPAN = Regex("<span style=\"font-size:(\\d+)px\">|<span[^>]*>|</span>")
private val ALIGNS = mapOf("left" to "start", "justify" to "start", "center" to "center", "right" to "end")
private val RULE = Regex("<hr\\s*/?>", RegexOption.IGNORE_CASE)
private val CLASS = Regex("class=\"([a-z]+)\"")
private val ALIGN = Regex("align=\"(left|center|right|justify)\"")
private val CELL_END = Regex("</t[dh]>")
private val ROW_END = Regex("</tr>")
private val CLASS_COLORS = mapOf(
ChatHtml.MUTED_CLASS to "#8c8c8c",

View File

@@ -0,0 +1,108 @@
package com.ts3client.android.ui
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.ts3client.net.Details
import com.ts3client.text.BBCode
/** The facts of the server, a channel or a client, then its description, for the top of its sheet. */
@Composable
fun DetailsSection(details: Details, onLink: (String) -> Unit, modifier: Modifier = Modifier) {
Column(modifier.padding(horizontal = 24.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
if (details.fields().isNotEmpty()) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
for (field in details.fields()) Fact(field.label(), field.value())
}
}
when {
details.description().isNotBlank() -> Description(details.description(), onLink)
details.pending() -> Text("Loading description…", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun Fact(label: String, value: String) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(label, Modifier.width(96.dp), style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(value, style = MaterialTheme.typography.bodySmall)
}
}
/** The description in a box that opens up, when there is more of it than fits folded. */
@Composable
private fun Description(bbcode: String, onLink: (String) -> Unit) {
val html = remember(bbcode) { BBCode.toHtml(bbcode) }
var expanded by rememberSaveable(bbcode) { mutableStateOf(false) }
var overflows by remember(bbcode) { mutableStateOf(false) }
val expandable = overflows || expanded
val background = MaterialTheme.colorScheme.surfaceContainerHigh
Surface(
Modifier.fillMaxWidth().then(if (expandable) Modifier.clickable { expanded = !expanded } else Modifier),
shape = RoundedCornerShape(12.dp),
color = background,
) {
Column(Modifier.animateContentSize().padding(12.dp)) {
Box {
Folded(FOLDED_HEIGHT, expanded, onOverflow = { overflows = it }) {
RichText(html, onLink, style = MaterialTheme.typography.bodyMedium)
}
if (overflows && !expanded) {
Box(Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(24.dp)
.background(Brush.verticalGradient(listOf(Color.Transparent, background))))
}
}
if (expandable) {
Text(if (expanded) "Less" else "More", Modifier.padding(top = 4.dp),
style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
}
}
}
}
/** Its content cut to [height] unless [open], telling whether there is more of it than that. */
@Composable
private fun Folded(height: Dp, open: Boolean, onOverflow: (Boolean) -> Unit, content: @Composable () -> Unit) {
val report by rememberUpdatedState(onOverflow)
Layout(content, Modifier.clipToBounds()) { measurables, constraints ->
val placeable = measurables.first().measure(constraints.copy(maxHeight = Constraints.Infinity))
val limit = height.roundToPx()
report(placeable.height > limit)
val shown = if (open) placeable.height else minOf(placeable.height, limit)
layout(placeable.width, shown) { placeable.place(0, 0) }
}
}
private val FOLDED_HEIGHT = 128.dp

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -64,11 +65,13 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.ChatKey
import com.ts3client.android.ServerController
import com.ts3client.android.ServerTree
import com.ts3client.android.SessionController
import com.ts3client.android.SessionState
import com.ts3client.config.Settings
import com.ts3client.net.ClientStatus
import com.ts3client.text.TsLink
import kotlinx.coroutines.flow.MutableStateFlow
private val ChatKeySaver = Saver<ChatKey, String>(
save = { key ->
@@ -110,6 +113,8 @@ fun ServerScreen(
val conversation = chats[chatKey] ?: chats.channel
var clientSheet by remember { mutableStateOf<Int?>(null) }
var channelSheet by remember { mutableStateOf<Int?>(null) }
var serverSheet by remember { mutableStateOf(false) }
val infoUpdates by server.infoUpdates.collectAsStateWithLifecycle()
val chatRequest by server.chatRequest.collectAsStateWithLifecycle()
LaunchedEffect(chatRequest) {
@@ -149,9 +154,17 @@ fun ServerScreen(
)
},
bottomBar = {
val self = tree.client(state.selfClientId)
// Push-to-talk talks on whichever server holds the microphone, which need not be this one.
val owner by vm.micOwner.collectAsStateWithLifecycle()
val ownerState by remember(owner) { owner?.state ?: MutableStateFlow(SessionState()) }
.collectAsStateWithLifecycle()
val ownerTree by remember(owner) { owner?.tree ?: MutableStateFlow(ServerTree()) }
.collectAsStateWithLifecycle()
val self = ownerTree.client(ownerState.selfClientId)
val talking = self?.status == ClientStatus.TALKING || self?.status == ClientStatus.COMMANDER_TALKING
VoiceBar(state, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) { showChat = !showChat }
VoiceBar(state, owner != null, talking, vm, server, onEnableMicrophone, showChat, chats.anyUnread) {
showChat = !showChat
}
},
snackbarHost = { SnackbarHost(snackbar) },
) { padding ->
@@ -165,6 +178,7 @@ fun ServerScreen(
onToggle = { server.toggleCollapsed(it.id) },
onChannel = { if (it.spacer == null) channelSheet = it.id },
onClient = { clientSheet = it.id },
onServer = { serverSheet = true },
modifier = Modifier.weight(1f),
)
} else {
@@ -187,9 +201,12 @@ fun ServerScreen(
// A sheet goes with its client or channel when that leaves the tree.
clientSheet?.let { id ->
LaunchedEffect(id) { server.requestClientDetails(id) }
tree.client(id)?.let { client ->
ClientSheet(
client = client,
details = remember(id, tree, infoUpdates) { server.clientDetails(id) },
onLink = onLink,
away = state.away,
initialVolume = remember(id) { server.clientVolume(id) },
onDismiss = { clientSheet = null },
@@ -208,9 +225,12 @@ fun ServerScreen(
}
}
channelSheet?.let { id ->
LaunchedEffect(id) { server.requestChannelDetails(id) }
tree.channel(id)?.let { channel ->
ChannelSheet(
channel = channel,
details = remember(id, tree, infoUpdates) { server.channelDetails(id) },
onLink = onLink,
current = id == state.channelId,
onDismiss = { channelSheet = null },
onJoin = { server.joinChannel(id, it); channelSheet = null },
@@ -218,6 +238,11 @@ fun ServerScreen(
)
}
}
if (serverSheet) {
remember(tree, infoUpdates) { server.serverDetails() }?.let {
ServerSheet(it, onLink, onDismiss = { serverSheet = false })
}
}
}
/**
@@ -227,6 +252,8 @@ fun ServerScreen(
@Composable
private fun VoiceBar(
state: SessionState,
/** Whether some server holds the microphone, for push-to-talk to talk on. */
captured: Boolean,
talking: Boolean,
vm: SessionController,
server: ServerController,
@@ -236,7 +263,7 @@ private fun VoiceBar(
onChat: () -> Unit,
) {
val context = LocalContext.current
val ptt = state.connected && state.micActive && state.inputMode == Settings.InputMode.PUSH_TO_TALK
val ptt = state.connected && captured && state.inputMode == Settings.InputMode.PUSH_TO_TALK
val mic = @Composable {
BarButton(
onClick = { server.setMicMuted(!state.micMuted) },
@@ -258,7 +285,12 @@ private fun VoiceBar(
else PackIcon("PLAYBACK", "Deafen")
}
}
// Without the talk button, what the microphone needs next: the permission, or to come to this server.
// Another server holds the microphone.
val activate = @Composable {
if (state.connected && state.micPermission && !state.micActive) {
IconButton(onClick = onEnableMicrophone) { PackIcon("ACTIVATE_MICROPHONE", "Speak on this server") }
}
}
val middle = @Composable {
when {
ptt -> PushToTalk(talking, vm::setPushToTalk, Modifier.fillMaxWidth())
@@ -266,8 +298,6 @@ private fun VoiceBar(
PackIcon("CAPTURE", null)
Text("Use microphone", Modifier.padding(start = 8.dp), maxLines = 1)
}
state.connected && !state.micActive ->
IconButton(onClick = onEnableMicrophone) { PackIcon("ACTIVATE_MICROPHONE", "Speak on this server") }
}
}
val away = @Composable {
@@ -284,17 +314,30 @@ private fun VoiceBar(
}
}
Surface(tonalElevation = 3.dp) {
// The same places whatever shows: equal sides keep the talk button's space centred.
// The same places whatever shows: sides of three buttons each keep the talk button centred.
Row(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically) {
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { mic(); deafen() }
Box(Modifier.weight(1.3f).height(48.dp), contentAlignment = Alignment.CenterStart) { middle() }
Row(Modifier.weight(1f), horizontalArrangement = Arrangement.SpaceEvenly) { away(); chat() }
Row(Modifier.width(SIDE), verticalAlignment = Alignment.CenterVertically) {
Slot { mic() }; Slot { deafen() }; Slot { activate() }
}
Box(Modifier.weight(1f).height(48.dp), contentAlignment = Alignment.Center) { middle() }
Row(Modifier.width(SIDE), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
Slot { away() }; Slot { chat() }
}
}
}
}
private val SLOT = 48.dp
private val SIDE = SLOT * 3
/** One button's place in the bar, kept whether or not the button shows. */
@Composable
private fun Slot(content: @Composable () -> Unit) {
Box(Modifier.size(SLOT), contentAlignment = Alignment.Center) { content() }
}
/** An icon button that also answers a long press. */
@Composable
private fun BarButton(onClick: () -> Unit, onLongClick: () -> Unit, enabled: Boolean, content: @Composable () -> Unit) {

View File

@@ -29,9 +29,12 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import com.ts3client.android.ChannelItem
import com.ts3client.android.ClientItem
import com.ts3client.contacts.ContactType
import com.ts3client.net.Details
import kotlin.math.roundToInt
/** What can be done with a client: the private chat, a poke, their volume, filing them as a contact. */
@@ -39,6 +42,8 @@ import kotlin.math.roundToInt
@Composable
fun ClientSheet(
client: ClientItem,
details: Details?,
onLink: (String) -> Unit,
away: Boolean,
initialVolume: Double,
onDismiss: () -> Unit,
@@ -52,7 +57,7 @@ fun ClientSheet(
) {
var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
StatusIcon(client.status)
@@ -62,6 +67,7 @@ fun ClientSheet(
if (client.awayMessage.isNotEmpty()) Text(client.awayMessage, style = MaterialTheme.typography.bodyMedium)
}
}
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
if (client.self) {
Action("AWAY", if (away) "Back" else "Away") { onAway(!away) }
} else {
@@ -106,6 +112,8 @@ fun ClientSheet(
@Composable
fun ChannelSheet(
channel: ChannelItem,
details: Details?,
onLink: (String) -> Unit,
current: Boolean,
onDismiss: () -> Unit,
onJoin: (String?) -> Unit,
@@ -113,9 +121,10 @@ fun ChannelSheet(
) {
var askPassword by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.navigationBarsPadding().padding(bottom = 16.dp)) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Text(channel.name, Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
style = MaterialTheme.typography.titleLarge)
details?.let { DetailsSection(it, onLink, Modifier.padding(bottom = 8.dp)) }
if (!current) {
Action("CHANNEL_SWITCH", "Join") {
if (channel.locked) askPassword = true else onJoin(null)
@@ -140,6 +149,22 @@ fun ChannelSheet(
}
}
/** The server's details: its version, who is on it, its welcome message. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerSheet(details: Details, onLink: (String) -> Unit, onDismiss: () -> Unit) {
ModalBottomSheet(onDismissRequest = onDismiss) {
Column(Modifier.verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 16.dp)) {
Row(Modifier.padding(horizontal = 24.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
PackIcon("SERVER_GREEN", null)
Text(details.title(), style = MaterialTheme.typography.titleLarge)
}
DetailsSection(details, onLink)
}
}
}
@Composable
private fun Action(iconKey: String, label: String, onClick: () -> Unit) {
ListItem(

View File

@@ -17,6 +17,10 @@ object StatusColors {
val blocked = Color(0xFFD9443A)
}
/** Links in chat and descriptions: blue, as links are, whatever the wallpaper's colours. */
@Composable
fun linkColor(): Color = if (isSystemInDarkTheme()) Color(0xFF7AB4FF) else Color(0xFF1A62D0)
/** The system's own (wallpaper) colours, which every supported Android version offers. */
@Composable
fun Ts3Theme(content: @Composable () -> Unit) {