Keep contacts on Android

A contacts page lists friends, blocked and neutral clients with when and
where each was last seen, and opens each one's settings: custom and
phonetic nickname, type, how the name is shown, what to ignore, whether
it may whisper, a volume change, and its unique ID, copied on a tap.
Changes are stored at once. Another page holds the defaults new contacts
start from, and a TeamSpeak 3 settings.db can be imported.

A client's sheet on a server adds them as a friend or blocks them, or
edits and removes the contact they already are. Everything else runs on
core's contact store as on desktop, so a change reaches the server's
playback and tree straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 10:52:56 +00:00
parent 5c0a5e4bcf
commit 570feffc25
8 changed files with 376 additions and 8 deletions

View File

@@ -60,7 +60,8 @@ data class ServerTree(val channels: List<ChannelItem> = emptyList()) {
awayMessage = if (away) c.awayMessage else "", awayMessage = if (away) c.awayMessage else "",
status = ClientStatus.of(c), status = ClientStatus.of(c),
self = c.self, self = c.self,
contact = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL, contactType = contact?.type,
highlight = if (colorContacts) contact?.type ?: ContactType.NEUTRAL else ContactType.NEUTRAL,
) )
} }
} }
@@ -89,5 +90,8 @@ data class ClientItem(
val awayMessage: String, val awayMessage: String,
val status: ClientStatus, val status: ClientStatus,
val self: Boolean, val self: Boolean,
val contact: ContactType, /** How the client is filed in the contacts; null when it is not one. */
val contactType: ContactType?,
/** The contact colour the name is drawn in, NEUTRAL when contacts are not coloured. */
val highlight: ContactType,
) )

View File

@@ -4,6 +4,7 @@ import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AndroidAudioBackend
import com.ts3client.android.audio.AudioRoute import com.ts3client.android.audio.AudioRoute
import com.ts3client.audio.AudioDevice import com.ts3client.audio.AudioDevice
@@ -11,7 +12,9 @@ import com.ts3client.config.Bookmark
import com.ts3client.config.Bookmarks import com.ts3client.config.Bookmarks
import com.ts3client.config.IdentityStore import com.ts3client.config.IdentityStore
import com.ts3client.config.Settings import com.ts3client.config.Settings
import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactStore import com.ts3client.contacts.ContactStore
import com.ts3client.contacts.ContactType
import com.ts3client.myts.MyTeamSpeak import com.ts3client.myts.MyTeamSpeak
import com.ts3client.myts.MyTeamSpeakLogin import com.ts3client.myts.MyTeamSpeakLogin
import com.ts3client.net.ChannelNode import com.ts3client.net.ChannelNode
@@ -20,6 +23,7 @@ import com.ts3client.session.ServerSession
import com.ts3client.sound.SoundNotifier import com.ts3client.sound.SoundNotifier
import com.ts3client.teamspeak.SyncItem import com.ts3client.teamspeak.SyncItem
import com.ts3client.teamspeak.TeamSpeakImporter import com.ts3client.teamspeak.TeamSpeakImporter
import com.ts3client.teamspeak.TeamSpeakSettingsDb
import com.ts3client.text.ChatHtml import com.ts3client.text.ChatHtml
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -32,6 +36,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
@@ -70,8 +75,9 @@ class SessionController(private val context: Context) {
private val audio = AndroidAudioBackend(context) private val audio = AndroidAudioBackend(context)
private val soundPlayer = audio.createSoundPlayer(settings) private val soundPlayer = audio.createSoundPlayer(settings)
private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) } private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) }
private val contactStore = ContactStore.load()
private val session = ServerSession( private val session = ServerSession(
settings, identities, audio, sounds, ContactStore.load(), Listener() settings, identities, audio, sounds, contactStore, Listener()
) )
private val conn get() = session.connection() private val conn get() = session.connection()
private val network = NetworkFollower(context, session, ::refresh, ::system) private val network = NetworkFollower(context, session, ::refresh, ::system)
@@ -85,6 +91,10 @@ class SessionController(private val context: Context) {
private val _chats = MutableStateFlow(Chats()) private val _chats = MutableStateFlow(Chats())
val chats: StateFlow<Chats> = _chats val chats: StateFlow<Chats> = _chats
/** Copies of the contacts, as the store's own entries change in place. */
private val _contacts = MutableStateFlow(contactCopies())
val contacts: StateFlow<List<Contact>> = _contacts
private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList()) private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList())
val bookmarks: StateFlow<List<Bookmark>> = _bookmarks val bookmarks: StateFlow<List<Bookmark>> = _bookmarks
@@ -133,6 +143,14 @@ class SessionController(private val context: Context) {
_inputDevices.value = audio.io().inputDevices() _inputDevices.value = audio.io().inputDevices()
_outputDevices.value = audio.io().outputDevices() _outputDevices.value = audio.io().outputDevices()
} }
// Mutations report on whichever thread made them, a connection thread included.
contactStore.addListener {
treeScope.launch {
_contacts.value = contactCopies()
conn.applyContacts()
}
scheduleTree(structure = false)
}
} }
val nickname: String get() = settings.nickname val nickname: String get() = settings.nickname
@@ -195,6 +213,40 @@ class SessionController(private val context: Context) {
_bookmarks.value = bookmarkStore.all().toList() _bookmarks.value = bookmarkStore.all().toList()
} }
// ---- contacts ----
private fun contactCopies(): List<Contact> = contactStore.all().map { it.copy() }
/** A copy of the contact to edit and hand back to [saveContact]. */
fun contact(uniqueId: String): Contact? = contactStore.find(uniqueId)?.copy()
/** 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
contactStore.add(client.uniqueId, client.nickname, type, settings.contactDefaults)
}
fun saveContact(contact: Contact) = contactStore.put(contact)
fun removeContact(uniqueId: String) = contactStore.remove(uniqueId)
/**
* Adds the contacts of a TeamSpeak 3 `settings.db`, overwriting entries for the same clients.
*
* @return how many contacts the file held and how many of them were new
*/
suspend fun importContacts(uri: Uri): Pair<Int, Int> = withContext(Dispatchers.IO) {
// The database reader needs a file it can seek in, which a content URI is not.
val copy = File.createTempFile("settings", ".db", context.cacheDir)
try {
context.contentResolver.openInputStream(uri)!!.use { input -> copy.outputStream().use { input.copyTo(it) } }
val found = TeamSpeakSettingsDb.readContacts(copy)
found.size to if (found.isEmpty()) 0 else contactStore.merge(found)
} finally {
copy.delete()
}
}
// ---- myTeamSpeak ---- // ---- myTeamSpeak ----
/** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */ /** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */

View File

@@ -26,6 +26,10 @@ fun App(vm: SessionController) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
var settingsOpen by rememberSaveable { mutableStateOf(false) } var settingsOpen by rememberSaveable { mutableStateOf(false) }
var contactsOpen by rememberSaveable { mutableStateOf(false) }
var editingContact by rememberSaveable { mutableStateOf<String?>(null) }
// Set when a contact's page was opened from a client, so leaving it goes back there.
var contactDirect by rememberSaveable { mutableStateOf(false) }
var poke by remember { mutableStateOf<SessionEvent.Poke?>(null) } var poke by remember { mutableStateOf<SessionEvent.Poke?>(null) }
LaunchedEffect(vm) { LaunchedEffect(vm) {
@@ -55,11 +59,23 @@ fun App(vm: SessionController) {
else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO)) else permissions.launch(arrayOf(Manifest.permission.RECORD_AUDIO))
} }
val openContacts: (String?) -> Unit = { uniqueId ->
contactsOpen = true
editingContact = uniqueId
contactDirect = uniqueId != null
}
CompositionLocalProvider(LocalIconTheme provides vm.icons) { CompositionLocalProvider(LocalIconTheme provides vm.icons) {
when { when {
settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false })
state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone) contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId ->
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking) if (uniqueId == null && contactDirect) contactsOpen = false
contactDirect = false
editingContact = uniqueId
}, onBack = { contactsOpen = false })
state.busy || state.connected ->
ServerScreen(vm, snackbar, { settingsOpen = true }, openContacts, enableMicrophone)
else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, { openContacts(null) }, connectAsking)
} }
} }

View File

@@ -165,7 +165,7 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) {
Text( Text(
client.name, client.name,
fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal, fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal,
color = when (client.contact) { color = when (client.highlight) {
ContactType.FRIEND -> StatusColors.friend ContactType.FRIEND -> StatusColors.friend
ContactType.BLOCKED -> StatusColors.blocked ContactType.BLOCKED -> StatusColors.blocked
else -> Color.Unspecified else -> Color.Unspecified

View File

@@ -43,6 +43,7 @@ fun ConnectScreen(
vm: SessionController, vm: SessionController,
snackbar: SnackbarHostState, snackbar: SnackbarHostState,
onSettings: () -> Unit, onSettings: () -> Unit,
onContacts: () -> Unit,
onConnect: (() -> Unit) -> Unit, onConnect: (() -> Unit) -> Unit,
) { ) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
@@ -56,7 +57,10 @@ fun ConnectScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("TeamSpeak") }, title = { Text("TeamSpeak") },
actions = { IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } }, actions = {
IconButton(onClick = onContacts) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
},
) )
}, },
snackbarHost = { SnackbarHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },

View File

@@ -0,0 +1,271 @@
package com.ts3client.android.ui
import android.content.ClipData
import android.content.ClipboardManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ts3client.android.SessionController
import com.ts3client.contacts.Contact
import com.ts3client.contacts.ContactDefaults
import com.ts3client.contacts.ContactType
import com.ts3client.contacts.NicknameDisplay
import kotlinx.coroutines.launch
import java.time.format.DateTimeFormatter
import kotlin.math.roundToInt
private val SEEN = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
private val DISPLAYS = listOf(
NicknameDisplay.ONLY_NICKNAME to "Nickname",
NicknameDisplay.ONLY_CUSTOM to "Custom name",
NicknameDisplay.BOTH to "Both",
)
private val GROUPS = listOf(
ContactType.FRIEND to "Friends",
ContactType.BLOCKED to "Blocked",
ContactType.NEUTRAL to "Neutral",
)
/**
* The contact list, grouped as TeamSpeak's Contacts window groups it, with each
* contact's own page and the defaults new contacts start out with.
*
* @param editing the unique id of the contact whose page is open, if any
*/
@Composable
fun ContactsScreen(vm: SessionController, editing: String?, onEdit: (String?) -> Unit, onBack: () -> Unit) {
var defaults by rememberSaveable { mutableStateOf(false) }
when {
editing != null -> return ContactScreen(vm, editing, onBack = { onEdit(null) })
defaults -> return ContactDefaultsScreen(vm, onBack = { defaults = false })
}
val contacts by vm.contacts.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var menu by remember { mutableStateOf(false) }
var imported by remember { mutableStateOf<String?>(null) }
val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) scope.launch {
imported = runCatching { vm.importContacts(uri) }.fold(
{ (found, added) ->
if (found == 0) "That file holds no contacts."
else "Imported $found contacts, $added of them new."
},
{ "That file is not a TeamSpeak 3 settings.db." },
)
}
}
SubScreen("Contacts", onBack, actions = {
Box {
IconButton(onClick = { menu = true }) { Icon(Icons.Default.MoreVert, "More") }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem({ Text("Defaults for new contacts") }, onClick = { menu = false; defaults = true })
DropdownMenuItem({ Text("Import from TeamSpeak 3") }, onClick = {
menu = false
importer.launch(arrayOf("*/*"))
})
}
}
}) { padding ->
LazyColumn(Modifier.padding(padding)) {
imported?.let { item { Hint(it) } }
if (contacts.isEmpty()) {
item {
Hint("No contacts yet. Tap a client on a server to add them as a friend or block them, " +
"or import the contacts of TeamSpeak 3 from its settings.db.")
}
}
for ((type, title) in GROUPS) {
val group = contacts.filter { it.type == type }.sortedBy { label(it).lowercase() }
if (group.isEmpty()) continue
item(key = type) { Section(title) }
items(group, key = { it.uniqueId }) { contact ->
ListItem(
headlineContent = { Text(label(contact), color = typeColor(contact.type)) },
supportingContent = { Text(lastSeen(contact)) },
modifier = Modifier.clickable { onEdit(contact.uniqueId) },
)
}
}
}
}
}
/** One contact's settings; each is stored as it is changed, the names once the page is left. */
@Composable
private fun ContactScreen(vm: SessionController, uniqueId: String, onBack: () -> Unit) {
val contacts by vm.contacts.collectAsStateWithLifecycle()
val contact = contacts.firstOrNull { it.uniqueId == uniqueId }
if (contact == null) {
LaunchedEffect(Unit) { onBack() }
return
}
// Starts from the stored entry, which may have been seen again since this page opened.
fun edit(change: Contact.() -> Unit) {
vm.contact(uniqueId)?.let { vm.saveContact(it.apply(change)) }
}
var customName by rememberSaveable(uniqueId) { mutableStateOf(contact.nickname) }
var phoneticName by rememberSaveable(uniqueId) { mutableStateOf(contact.phoneticNickname) }
var removing by remember { mutableStateOf(false) }
val leave = {
if (customName.trim() != contact.nickname || phoneticName.trim() != contact.phoneticNickname) {
edit {
nickname = customName.trim()
phoneticNickname = phoneticName.trim()
}
}
onBack()
}
SubScreen(label(contact), leave, actions = {
IconButton(onClick = { removing = true }) { PackIcon("DELETE", "Remove from contacts") }
}) { padding ->
Column(Modifier.padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(vertical = 8.dp)) {
OutlinedTextField(customName, { customName = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp),
label = { Text("Custom nickname") }, singleLine = true)
OutlinedTextField(phoneticName, { phoneticName = it }, Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
label = { Text("Phonetic nickname") }, singleLine = true)
Section("Contact")
Segments(GROUPS.map { (type, _) -> type to type.label() }, contact.type) { edit { type = it } }
Section("Show as")
Segments(DISPLAYS, contact.nicknameDisplay) { edit { nicknameDisplay = it } }
Section("Ignore")
Toggle("Mute", contact.automute) { edit { automute = it } }
Toggle("Ignore server and channel chat", contact.ignorePublicMessages) { edit { ignorePublicMessages = it } }
Toggle("Ignore private chat", contact.ignorePrivateMessages) { edit { ignorePrivateMessages = it } }
Toggle("Ignore pokes", contact.ignorePokes) { edit { ignorePokes = it } }
Toggle("Hide avatar", contact.ignoreAvatar) { edit { ignoreAvatar = it } }
Toggle("Hide away message", contact.ignoreAwayMessage) { edit { ignoreAwayMessage = it } }
Toggle("Deny whisper", !contact.whisperAllowed) { edit { whisperAllowed = !it } }
Section("Playback")
val volume = if (contact.hasVolumeModifier) contact.volumeModifier.toFloat() else 0f
Level("Volume", volume, -30f..20f, ::formatVolume) { db ->
val rounded = db.roundToInt().toDouble()
edit {
hasVolumeModifier = rounded != 0.0
volumeModifier = rounded
}
}
Section("Details")
val context = LocalContext.current
LinkRow("Unique ID", contact.uniqueId) {
// The system confirms the copy itself.
context.getSystemService(ClipboardManager::class.java)
.setPrimaryClip(ClipData.newPlainText("Unique ID", contact.uniqueId))
}
ListItem(headlineContent = { Text(lastSeen(contact)) })
}
}
if (removing) {
AlertDialog(
onDismissRequest = { removing = false },
title = { Text("Remove ${label(contact)}?") },
text = { Text("Their settings here are forgotten; they show as anyone else again.") },
confirmButton = {
TextButton(onClick = {
removing = false
vm.removeContact(uniqueId)
onBack()
}) { Text("Remove") }
},
dismissButton = { TextButton(onClick = { removing = false }) { Text("Cancel") } },
)
}
}
/** What a client starts out with when added as a friend, blocked or neutral. */
@Composable
private fun ContactDefaultsScreen(vm: SessionController, onBack: () -> Unit) {
val defaults = vm.settings.contactDefaults
var tab by rememberSaveable { mutableStateOf(ContactType.FRIEND) }
var entry by remember(tab) { mutableStateOf(ContactDefaults.Entry().apply { copyFrom(defaults.of(tab)) }) }
fun edit(change: ContactDefaults.Entry.() -> Unit) {
val changed = ContactDefaults.Entry().apply { copyFrom(entry); change() }
defaults.of(tab).copyFrom(changed)
vm.settings.save()
entry = changed
}
SubScreen("Defaults for new contacts", onBack) { padding ->
Column(Modifier.padding(padding)) {
PrimaryTabRow(selectedTabIndex = GROUPS.indexOfFirst { it.first == tab }) {
for ((type, title) in GROUPS) Tab(tab == type, onClick = { tab = type }, text = { Text(title) })
}
Column(Modifier.verticalScroll(rememberScrollState()).padding(bottom = 8.dp)) {
Section("Show as")
Segments(DISPLAYS, entry.nicknameDisplay) { edit { nicknameDisplay = it } }
Section("Ignore")
Toggle("Mute", entry.automute) { edit { automute = it } }
Toggle("Ignore server and channel chat", entry.ignorePublicMessages) { edit { ignorePublicMessages = it } }
Toggle("Ignore private chat", entry.ignorePrivateMessages) { edit { ignorePrivateMessages = it } }
Toggle("Ignore pokes", entry.ignorePokes) { edit { ignorePokes = it } }
Toggle("Hide avatar", entry.ignoreAvatar) { edit { ignoreAvatar = it } }
Toggle("Hide away message", entry.ignoreAwayMessage) { edit { ignoreAwayMessage = it } }
Toggle("Deny whisper", entry.ignoreWhisper) { edit { ignoreWhisper = it } }
}
}
}
}
private fun label(contact: Contact) = contact.nickname.ifBlank { contact.uniqueId }
private fun lastSeen(contact: Contact): String {
val seen = contact.lastSeen ?: return "Never seen"
val where = contact.lastSeenServerName.ifBlank { contact.lastSeenServerAddress }
return "Last seen ${SEEN.format(seen)}" + if (where.isNotBlank()) " on $where" else ""
}
private fun formatVolume(db: Float): String {
val rounded = db.roundToInt()
return if (rounded == 0) "Unchanged" else "${if (rounded > 0) "+" else ""}$rounded dB"
}
private fun typeColor(type: ContactType): Color = when (type) {
ContactType.FRIEND -> StatusColors.friend
ContactType.BLOCKED -> StatusColors.blocked
ContactType.NEUTRAL -> Color.Unspecified
}

View File

@@ -76,6 +76,8 @@ fun ServerScreen(
vm: SessionController, vm: SessionController,
snackbar: SnackbarHostState, snackbar: SnackbarHostState,
onSettings: () -> Unit, onSettings: () -> Unit,
/** Opens the contacts, on the given client's page when one is named. */
onContacts: (String?) -> Unit,
onEnableMicrophone: () -> Unit, onEnableMicrophone: () -> Unit,
) { ) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
@@ -118,6 +120,7 @@ fun ServerScreen(
} }
}, },
actions = { actions = {
IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") }
IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") }
IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") } IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") }
}, },
@@ -182,6 +185,9 @@ fun ServerScreen(
onPoke = { vm.poke(id, it); clientSheet = null }, onPoke = { vm.poke(id, it); clientSheet = null },
onVolume = { vm.setClientVolume(id, it) }, onVolume = { vm.setClientVolume(id, it) },
onAway = { vm.setAway(it); clientSheet = null }, onAway = { vm.setAway(it); clientSheet = null },
onAddContact = { vm.addContact(id, it) },
onEditContact = { clientSheet = null; onContacts(client.uniqueId) },
onRemoveContact = { vm.removeContact(client.uniqueId) },
) )
} }
} }

View File

@@ -31,9 +31,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import com.ts3client.android.ChannelItem import com.ts3client.android.ChannelItem
import com.ts3client.android.ClientItem import com.ts3client.android.ClientItem
import com.ts3client.contacts.ContactType
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** What can be done with a client: the private chat, a poke, their volume. */ /** What can be done with a client: the private chat, a poke, their volume, filing them as a contact. */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ClientSheet( fun ClientSheet(
@@ -45,6 +46,9 @@ fun ClientSheet(
onPoke: (String) -> Unit, onPoke: (String) -> Unit,
onVolume: (Double) -> Unit, onVolume: (Double) -> Unit,
onAway: (Boolean) -> Unit, onAway: (Boolean) -> Unit,
onAddContact: (ContactType) -> Unit,
onEditContact: () -> Unit,
onRemoveContact: () -> Unit,
) { ) {
var poking by remember { mutableStateOf(false) } var poking by remember { mutableStateOf(false) }
ModalBottomSheet(onDismissRequest = onDismiss) { ModalBottomSheet(onDismissRequest = onDismiss) {
@@ -63,6 +67,17 @@ fun ClientSheet(
} else { } else {
Action("PLAYER_CHAT", "Send message", onMessage) Action("PLAYER_CHAT", "Send message", onMessage)
Action("POKE", "Poke") { poking = true } Action("POKE", "Poke") { poking = true }
when (client.contactType) {
null -> {
Action("ADD_FRIEND", "Add as friend") { onAddContact(ContactType.FRIEND) }
Action("ADD_FOE", "Block") { onAddContact(ContactType.BLOCKED) }
}
else -> {
Action("EDIT_FRIEND_FOE_STATUS", "Edit contact", onEditContact)
Action(if (client.contactType == ContactType.BLOCKED) "REMOVE_FOE" else "REMOVE_FRIEND",
"Remove from contacts", onRemoveContact)
}
}
var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) } var volume by remember { mutableFloatStateOf(initialVolume.toFloat()) }
Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB", Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB",
Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium) Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium)