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:
@@ -60,7 +60,8 @@ data class ServerTree(val channels: List<ChannelItem> = 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,
|
||||
)
|
||||
|
||||
@@ -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> = _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())
|
||||
val bookmarks: StateFlow<List<Bookmark>> = _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<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 ----
|
||||
|
||||
/** The account the app stays signed in to; its reads block, so they run on [Dispatchers.IO]. */
|
||||
|
||||
@@ -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<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) }
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user