From 570feffc25789d8a98cbe51eb16e40fe2e89814a Mon Sep 17 00:00:00 2001 From: ericek111 Date: Sat, 26 Sep 2026 10:52:56 +0000 Subject: [PATCH] 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 --- .../java/com/ts3client/android/ServerTree.kt | 8 +- .../ts3client/android/SessionController.kt | 54 +++- .../main/java/com/ts3client/android/ui/App.kt | 20 +- .../com/ts3client/android/ui/ChannelTree.kt | 2 +- .../com/ts3client/android/ui/ConnectScreen.kt | 6 +- .../ts3client/android/ui/ContactsScreen.kt | 271 ++++++++++++++++++ .../com/ts3client/android/ui/ServerScreen.kt | 6 + .../java/com/ts3client/android/ui/Sheets.kt | 17 +- 8 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 android/app/src/main/java/com/ts3client/android/ui/ContactsScreen.kt diff --git a/android/app/src/main/java/com/ts3client/android/ServerTree.kt b/android/app/src/main/java/com/ts3client/android/ServerTree.kt index b26875b..bb03747 100644 --- a/android/app/src/main/java/com/ts3client/android/ServerTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ServerTree.kt @@ -60,7 +60,8 @@ data class ServerTree(val channels: List = emptyList()) { awayMessage = if (away) c.awayMessage else "", status = ClientStatus.of(c), 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 status: ClientStatus, 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, ) diff --git a/android/app/src/main/java/com/ts3client/android/SessionController.kt b/android/app/src/main/java/com/ts3client/android/SessionController.kt index 84c6be7..078c4cf 100644 --- a/android/app/src/main/java/com/ts3client/android/SessionController.kt +++ b/android/app/src/main/java/com/ts3client/android/SessionController.kt @@ -4,6 +4,7 @@ import android.Manifest import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri import com.ts3client.android.audio.AndroidAudioBackend import com.ts3client.android.audio.AudioRoute import com.ts3client.audio.AudioDevice @@ -11,7 +12,9 @@ import com.ts3client.config.Bookmark import com.ts3client.config.Bookmarks import com.ts3client.config.IdentityStore import com.ts3client.config.Settings +import com.ts3client.contacts.Contact import com.ts3client.contacts.ContactStore +import com.ts3client.contacts.ContactType import com.ts3client.myts.MyTeamSpeak import com.ts3client.myts.MyTeamSpeakLogin import com.ts3client.net.ChannelNode @@ -20,6 +23,7 @@ import com.ts3client.session.ServerSession import com.ts3client.sound.SoundNotifier import com.ts3client.teamspeak.SyncItem import com.ts3client.teamspeak.TeamSpeakImporter +import com.ts3client.teamspeak.TeamSpeakSettingsDb import com.ts3client.text.ChatHtml import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -32,6 +36,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -70,8 +75,9 @@ class SessionController(private val context: Context) { private val audio = AndroidAudioBackend(context) private val soundPlayer = audio.createSoundPlayer(settings) private val sounds = SoundNotifier(settings).also { it.setPlayer(soundPlayer) } + private val contactStore = ContactStore.load() 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 network = NetworkFollower(context, session, ::refresh, ::system) @@ -85,6 +91,10 @@ class SessionController(private val context: Context) { private val _chats = MutableStateFlow(Chats()) val chats: StateFlow = _chats + /** Copies of the contacts, as the store's own entries change in place. */ + private val _contacts = MutableStateFlow(contactCopies()) + val contacts: StateFlow> = _contacts + private val _bookmarks = MutableStateFlow(bookmarkStore.all().toList()) val bookmarks: StateFlow> = _bookmarks @@ -133,6 +143,14 @@ class SessionController(private val context: Context) { _inputDevices.value = audio.io().inputDevices() _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 @@ -195,6 +213,40 @@ class SessionController(private val context: Context) { _bookmarks.value = bookmarkStore.all().toList() } + // ---- contacts ---- + + private fun contactCopies(): List = 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 = 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 ---- /** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */ diff --git a/android/app/src/main/java/com/ts3client/android/ui/App.kt b/android/app/src/main/java/com/ts3client/android/ui/App.kt index 1cac570..cd1f8ae 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/App.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/App.kt @@ -26,6 +26,10 @@ fun App(vm: SessionController) { val state by vm.state.collectAsStateWithLifecycle() val snackbar = remember { SnackbarHostState() } var settingsOpen by rememberSaveable { mutableStateOf(false) } + var contactsOpen by rememberSaveable { mutableStateOf(false) } + var editingContact by rememberSaveable { mutableStateOf(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(null) } LaunchedEffect(vm) { @@ -55,11 +59,23 @@ fun App(vm: SessionController) { 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) { when { settingsOpen -> SettingsScreen(vm, onBack = { settingsOpen = false }) - state.busy || state.connected -> ServerScreen(vm, snackbar, { settingsOpen = true }, enableMicrophone) - else -> ConnectScreen(vm, snackbar, { settingsOpen = true }, connectAsking) + contactsOpen -> ContactsScreen(vm, editingContact, onEdit = { uniqueId -> + 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) } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt index e6f6aa2..9294a74 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ChannelTree.kt @@ -165,7 +165,7 @@ private fun ClientRow(row: TreeRow.Client, onClient: (ClientItem) -> Unit) { Text( client.name, fontWeight = if (client.self) FontWeight.Bold else FontWeight.Normal, - color = when (client.contact) { + color = when (client.highlight) { ContactType.FRIEND -> StatusColors.friend ContactType.BLOCKED -> StatusColors.blocked else -> Color.Unspecified diff --git a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt index 7a28235..6a0f82a 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ConnectScreen.kt @@ -43,6 +43,7 @@ fun ConnectScreen( vm: SessionController, snackbar: SnackbarHostState, onSettings: () -> Unit, + onContacts: () -> Unit, onConnect: (() -> Unit) -> Unit, ) { val state by vm.state.collectAsStateWithLifecycle() @@ -56,7 +57,10 @@ fun ConnectScreen( topBar = { TopAppBar( 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) }, diff --git a/android/app/src/main/java/com/ts3client/android/ui/ContactsScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ContactsScreen.kt new file mode 100644 index 0000000..a8bd8f2 --- /dev/null +++ b/android/app/src/main/java/com/ts3client/android/ui/ContactsScreen.kt @@ -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(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 +} diff --git a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt index a5b6271..a4a12d7 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/ServerScreen.kt @@ -76,6 +76,8 @@ fun ServerScreen( vm: SessionController, snackbar: SnackbarHostState, onSettings: () -> Unit, + /** Opens the contacts, on the given client's page when one is named. */ + onContacts: (String?) -> Unit, onEnableMicrophone: () -> Unit, ) { val state by vm.state.collectAsStateWithLifecycle() @@ -118,6 +120,7 @@ fun ServerScreen( } }, actions = { + IconButton(onClick = { onContacts(null) }) { PackIcon("CONTACT", "Contacts") } IconButton(onClick = onSettings) { PackIcon("SETTINGS", "Settings") } IconButton(onClick = vm::disconnect) { PackIcon("DISCONNECT", "Disconnect") } }, @@ -182,6 +185,9 @@ fun ServerScreen( onPoke = { vm.poke(id, it); clientSheet = null }, onVolume = { vm.setClientVolume(id, it) }, onAway = { vm.setAway(it); clientSheet = null }, + onAddContact = { vm.addContact(id, it) }, + onEditContact = { clientSheet = null; onContacts(client.uniqueId) }, + onRemoveContact = { vm.removeContact(client.uniqueId) }, ) } } diff --git a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt index c9ba7a6..1bfc512 100644 --- a/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt +++ b/android/app/src/main/java/com/ts3client/android/ui/Sheets.kt @@ -31,9 +31,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.foundation.clickable import com.ts3client.android.ChannelItem import com.ts3client.android.ClientItem +import com.ts3client.contacts.ContactType 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) @Composable fun ClientSheet( @@ -45,6 +46,9 @@ fun ClientSheet( onPoke: (String) -> Unit, onVolume: (Double) -> Unit, onAway: (Boolean) -> Unit, + onAddContact: (ContactType) -> Unit, + onEditContact: () -> Unit, + onRemoveContact: () -> Unit, ) { var poking by remember { mutableStateOf(false) } ModalBottomSheet(onDismissRequest = onDismiss) { @@ -63,6 +67,17 @@ fun ClientSheet( } else { Action("PLAYER_CHAT", "Send message", onMessage) 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()) } Text("Volume: ${if (volume > 0) "+" else ""}${volume.roundToInt()} dB", Modifier.padding(start = 24.dp, top = 8.dp), style = MaterialTheme.typography.bodyMedium)