Android: Design settings page, Contacts in Settings, server icons in the switcher

Appearance options move to their own Design page. Contacts is the first
Settings entry instead of a top-bar button. The top bar and the server
switcher show each server's own icon, else the pack's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 23:12:16 +00:00
parent 4911542d39
commit 2d83eafffd
4 changed files with 33 additions and 12 deletions

View File

@@ -73,12 +73,12 @@ fun App(vm: SessionController) {
CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) { CompositionLocalProvider(LocalIconTheme provides vm.icons, LocalWebPictures provides vm.pictures) {
val server = current val server = current
when { when {
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId -> contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId ->
if (uniqueId == null && contactDirect) contactsOpen = false if (uniqueId == null && contactDirect) contactsOpen = false
contactDirect = false contactDirect = false
editingContact = uniqueId editingContact = uniqueId
}, onBack = { contactsOpen = false }) }, onBack = { contactsOpen = false })
settingsOpen -> SettingsScreen(vm, onContacts = { openContacts(null) }, onBack = { settingsOpen = false })
server != null && !connectOpen -> { server != null && !connectOpen -> {
serverStates.SaveableStateProvider(server.id) { serverStates.SaveableStateProvider(server.id) {
CompositionLocalProvider(LocalServerImages provides server.images) { CompositionLocalProvider(LocalServerImages provides server.images) {
@@ -89,7 +89,7 @@ fun App(vm: SessionController) {
} }
} }
} }
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking, else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking,
onBack = if (current != null) ({ connectOpen = false }) else null) onBack = if (current != null) ({ connectOpen = false }) else null)
} }
} }

View File

@@ -64,7 +64,6 @@ fun ConnectScreen(
vm: SessionController, vm: SessionController,
snackbar: SnackbarHostState, snackbar: SnackbarHostState,
onSettings: () -> Unit, onSettings: () -> Unit,
onContacts: () -> Unit,
onConnect: (() -> Unit) -> Unit, onConnect: (() -> Unit) -> Unit,
/** Back to the servers already connected; null when there are none. */ /** Back to the servers already connected; null when there are none. */
onBack: (() -> Unit)?, onBack: (() -> Unit)?,
@@ -89,7 +88,6 @@ fun ConnectScreen(
if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } if (onBack != null) IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
}, },
actions = { actions = {
IconButton(onClick = onContacts) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
}, },
) )

View File

@@ -2,6 +2,7 @@ package com.ts3client.android.ui
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
@@ -108,8 +109,8 @@ fun ServerScreen(
server: ServerController, server: ServerController,
snackbar: SnackbarHostState, snackbar: SnackbarHostState,
onSettings: () -> Unit, onSettings: () -> Unit,
/** Opens the contacts, on the given client's page when one is named. */ /** Opens the given client's contact page. */
onContacts: (String?) -> Unit, onEditContact: (String) -> Unit,
onEnableMicrophone: () -> Unit, onEnableMicrophone: () -> Unit,
onConnectOther: () -> Unit, onConnectOther: () -> Unit,
) { ) {
@@ -233,7 +234,6 @@ fun ServerScreen(
actions = { actions = {
state.hostButton?.let { HostButtonAction(it) { url -> runCatching { uriHandler.openUri(url) } } } state.hostButton?.let { HostButtonAction(it) { url -> runCatching { uriHandler.openUri(url) } } }
IconButton(onClick = find::show) { PackIcon("FIND", "Find") } IconButton(onClick = find::show) { PackIcon("FIND", "Find") }
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
}, },
) )
@@ -333,7 +333,7 @@ fun ServerScreen(
onVolume = { server.setClientVolume(id, it) }, onVolume = { server.setClientVolume(id, it) },
onAway = { server.setAway(it); clientSheet = null }, onAway = { server.setAway(it); clientSheet = null },
onAddContact = { server.addContact(id, it) }, onAddContact = { server.addContact(id, it) },
onEditContact = { clientSheet = null; onContacts(client.uniqueId) }, onEditContact = { clientSheet = null; onEditContact(client.uniqueId) },
onMove = if (server.canMoveClients) ({ clientSheet = null; moving = id }) else null, onMove = if (server.canMoveClients) ({ clientSheet = null; moving = id }) else null,
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 }
@@ -523,6 +523,16 @@ private fun PushToTalk(talking: Boolean, onPressed: (Boolean) -> Unit, modifier:
} }
} }
/** The server's own icon, else the pack's server icon. */
@Composable
private fun ServerLogo(server: ServerController, modifier: Modifier = Modifier) {
val tree by server.tree.collectAsStateWithLifecycle()
val version by server.images.version.collectAsStateWithLifecycle()
val icon = remember(tree.serverIconId, version) { server.images.icon(tree.serverIconId) }
if (icon != null) Image(icon, null, modifier.size(24.dp))
else PackIcon("SERVER_GREEN", null, modifier)
}
/** The server on screen, opening onto the others and connecting to another. */ /** The server on screen, opening onto the others and connecting to another. */
@Composable @Composable
private fun ServerSwitcher(vm: SessionController, server: ServerController, state: SessionState, onConnectOther: () -> Unit) { private fun ServerSwitcher(vm: SessionController, server: ServerController, state: SessionState, onConnectOther: () -> Unit) {
@@ -531,6 +541,7 @@ private fun ServerSwitcher(vm: SessionController, server: ServerController, stat
Box(Modifier.fillMaxWidth()) { Box(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { open = true }, Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).clickable { open = true },
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
ServerLogo(server, Modifier.padding(end = 12.dp))
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis) Text(state.title, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (!state.connected) { if (!state.connected) {
@@ -548,7 +559,7 @@ private fun ServerSwitcher(vm: SessionController, server: ServerController, stat
text = { Text(otherState.title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, text = { Text(otherState.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
onClick = { open = false; vm.select(other) }, onClick = { open = false; vm.select(other) },
leadingIcon = { leadingIcon = {
PackIcon("SERVER_GREEN", null, Modifier.alpha(if (otherState.connected) 1f else 0.5f)) ServerLogo(other, Modifier.alpha(if (otherState.connected) 1f else 0.5f))
}, },
trailingIcon = if (otherState.micActive) ({ PackIcon("ACTIVATE_MICROPHONE", "Microphone") }) else null, trailingIcon = if (otherState.micActive) ({ PackIcon("ACTIVATE_MICROPHONE", "Microphone") }) else null,
colors = if (other == server) MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.primary) colors = if (other == server) MenuDefaults.itemColors(textColor = MaterialTheme.colorScheme.primary)

View File

@@ -21,17 +21,18 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
private enum class SettingsPage { SOUND, IDENTITIES, MY_TEAMSPEAK } private enum class SettingsPage { SOUND, DESIGN, IDENTITIES, MY_TEAMSPEAK }
/** /**
* The settings, by category. Every change is saved and put into effect at once, * The settings, by category. Every change is saved and put into effect at once,
* as there is no dialog to confirm. * as there is no dialog to confirm.
*/ */
@Composable @Composable
fun SettingsScreen(vm: SessionController, onBack: () -> Unit) { fun SettingsScreen(vm: SessionController, onContacts: () -> Unit, onBack: () -> Unit) {
var page by rememberSaveable { mutableStateOf<SettingsPage?>(null) } var page by rememberSaveable { mutableStateOf<SettingsPage?>(null) }
when (page) { when (page) {
SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null }) SettingsPage.SOUND -> return SoundSettingsScreen(vm, onBack = { page = null })
SettingsPage.DESIGN -> return DesignScreen(vm, onBack = { page = null })
SettingsPage.IDENTITIES -> return IdentitiesScreen(vm.identities, onBack = { page = null }) SettingsPage.IDENTITIES -> return IdentitiesScreen(vm.identities, onBack = { page = null })
SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null }) SettingsPage.MY_TEAMSPEAK -> return MyTeamSpeakScreen(vm, onBack = { page = null })
null -> {} null -> {}
@@ -39,16 +40,27 @@ fun SettingsScreen(vm: SessionController, onBack: () -> Unit) {
SubScreen("Settings", onBack) { padding -> SubScreen("Settings", onBack) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) { Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
LinkRow("Contacts", "Friends and blocked users", "CONTACT", onContacts)
LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") { LinkRow("Sound Settings", "Devices, transmission, voice activation, processing, volume", "PLAYBACK") {
page = SettingsPage.SOUND page = SettingsPage.SOUND
} }
LinkRow("Design", "Icon pack, country flags, description pictures", "GUISETUP") {
page = SettingsPage.DESIGN
}
LinkRow("Identities", "Create, import, export, security level", LinkRow("Identities", "Create, import, export, security level",
"IDENTITY_MANAGER") { page = SettingsPage.IDENTITIES } "IDENTITY_MANAGER") { page = SettingsPage.IDENTITIES }
LinkRow("myTeamSpeak", LinkRow("myTeamSpeak",
if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}" if (vm.myTeamSpeak.isSignedIn) "Signed in as ${vm.myTeamSpeak.username()}"
else "Sign in to bring over your synchronised bookmarks and identities", else "Sign in to bring over your synchronised bookmarks and identities",
"MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK } "MYTS_ACCOUNT") { page = SettingsPage.MY_TEAMSPEAK }
Section("Appearance") }
}
}
@Composable
private fun DesignScreen(vm: SessionController, onBack: () -> Unit) {
SubScreen("Design", onBack) { padding ->
Column(Modifier.padding(padding).verticalScroll(rememberScrollState())) {
IconPackPicker(vm.icons) IconPackPicker(vm.icons)
var flags by remember { mutableStateOf(vm.settings.countryFlags) } var flags by remember { mutableStateOf(vm.settings.countryFlags) }
Toggle("Show country flags in the channel tree", flags) { Toggle("Show country flags in the channel tree", flags) {