chatgui/chatgui-android: уведомления со звуком, флаги и прочие улучшения UI
This commit is contained in:
1 parent
6c857ceab8
commit
0f308fdbd7
20 files changed
+292
-32
No files matched your search
@@ -5,6 +5,7 @@ import android.util.Log
|
||||
import com.utun.chat.data.LogManager
|
||||
import com.utun.chat.data.ConfigProvider
|
||||
import com.utun.chat.data.NativeLib
|
||||
import com.utun.chat.data.NotificationSound
|
||||
import com.utun.chat.data.ChatEventCallback
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@@ -29,6 +30,10 @@ class ChatApplication : Application() {
|
||||
logManager = LogManager
|
||||
configProvider = ConfigProvider(this)
|
||||
|
||||
/* Notification sound state: global toggle + per-group mute */
|
||||
NotificationSound.enabled = configProvider.getSoundEnabled()
|
||||
NotificationSound.mutedChannels = configProvider.getMutedChannels()
|
||||
|
||||
/* One-time event callback registration — survives ViewModel recreation */
|
||||
NativeLib.setEventCallback(AppEventHandler)
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -48,6 +49,8 @@ class ConfigProvider(private val context: Context) {
|
||||
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
|
||||
private val connectionModeKey = stringPreferencesKey("connection_mode")
|
||||
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
|
||||
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
|
||||
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
|
||||
|
||||
private fun keysFilePath(): String {
|
||||
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
|
||||
@@ -220,6 +223,27 @@ class ConfigProvider(private val context: Context) {
|
||||
context.dataStore.edit { prefs -> prefs[serviceEnabledKey] = enabled }
|
||||
}
|
||||
|
||||
/* ── Notification sound (global toggle + per-group mute) ── */
|
||||
|
||||
fun getSoundEnabled(): Boolean = runBlocking {
|
||||
context.dataStore.data.first()[soundEnabledKey] ?: true
|
||||
}
|
||||
|
||||
suspend fun setSoundEnabled(enabled: Boolean) {
|
||||
context.dataStore.edit { prefs -> prefs[soundEnabledKey] = enabled }
|
||||
}
|
||||
|
||||
fun getMutedChannels(): Set<String> = runBlocking {
|
||||
context.dataStore.data.first()[mutedChannelsKey] ?: emptySet()
|
||||
}
|
||||
|
||||
suspend fun setChannelMuted(channelId: String, muted: Boolean) {
|
||||
context.dataStore.edit { prefs ->
|
||||
val cur = prefs[mutedChannelsKey] ?: emptySet()
|
||||
prefs[mutedChannelsKey] = if (muted) cur + channelId else cur - channelId
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Server entries ── */
|
||||
|
||||
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.utun.chat.data
|
||||
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.R
|
||||
|
||||
/* Уведомительный звук входящих сообщений (msg_incoming.mp3).
|
||||
Глобальный тумблер (enabled) + per-group mute (mutedChannels). */
|
||||
object NotificationSound {
|
||||
@Volatile var enabled: Boolean = true
|
||||
@Volatile var mutedChannels: Set<String> = emptySet()
|
||||
|
||||
fun isMuted(channelId: String): Boolean = channelId in mutedChannels
|
||||
|
||||
fun setMuted(channelId: String, muted: Boolean) {
|
||||
mutedChannels = if (muted) mutedChannels + channelId else mutedChannels - channelId
|
||||
}
|
||||
|
||||
fun play(channelId: String) {
|
||||
if (!enabled || isMuted(channelId)) return
|
||||
try {
|
||||
val mp = MediaPlayer.create(ChatApplication.instance, R.raw.msg_incoming)
|
||||
?: return
|
||||
mp.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
mp.setOnCompletionListener { it.release() }
|
||||
mp.start()
|
||||
LogManager.addLog("DEBUG", "Sound", "notification played ch=$channelId")
|
||||
} catch (e: Exception) {
|
||||
LogManager.addLog("WARN", "Sound", "play failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+57
-1
@@ -23,6 +23,9 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.utun.chat.data.Channel
|
||||
import com.utun.chat.data.NotificationSound
|
||||
import com.utun.chat.ChatApplication
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -39,6 +42,8 @@ fun ChannelListScreen(
|
||||
onRefresh: () -> Unit = {}
|
||||
) {
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var menuChannelId by remember { mutableStateOf<String?>(null) }
|
||||
var optionsChannel by remember { mutableStateOf<Channel?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) { onRefresh() }
|
||||
|
||||
@@ -88,9 +93,22 @@ fun ChannelListScreen(
|
||||
Box(Modifier.width(4.dp).fillMaxHeight().background(barColor))
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
|
||||
IconButton(onClick = { onInviteClick(ch) }, modifier = Modifier.size(44.dp)) {
|
||||
IconButton(onClick = { menuChannelId = ch.id }, modifier = Modifier.size(44.dp)) {
|
||||
Text("#", fontSize = 22.sp)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = menuChannelId == ch.id,
|
||||
onDismissRequest = { menuChannelId = null }
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Invite link") },
|
||||
onClick = { menuChannelId = null; onInviteClick(ch) }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("Options") },
|
||||
onClick = { menuChannelId = null; optionsChannel = ch }
|
||||
)
|
||||
}
|
||||
if (badgeText.isNotEmpty()) {
|
||||
val badgeSize = 18.dp
|
||||
Box(
|
||||
@@ -137,6 +155,44 @@ fun ChannelListScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
optionsChannel?.let { ch ->
|
||||
ChannelOptionsDialog(
|
||||
channel = ch,
|
||||
onDismiss = { optionsChannel = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var soundOn by remember(channel.id) { mutableStateOf(!NotificationSound.isMuted(channel.id)) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(channel.name) },
|
||||
text = {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Sound", modifier = Modifier.weight(1f))
|
||||
Switch(
|
||||
checked = soundOn,
|
||||
onCheckedChange = {
|
||||
soundOn = it
|
||||
NotificationSound.setMuted(channel.id, !it)
|
||||
scope.launch {
|
||||
ChatApplication.instance.configProvider.setChannelMuted(channel.id, !it)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Close") }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.utun.chat.ChatApplication
|
||||
import com.utun.chat.data.NativeLib
|
||||
import com.utun.chat.data.NotificationSound
|
||||
import com.utun.chat.data.ServerEntry
|
||||
import com.utun.chat.data.LocalSocketState
|
||||
import com.utun.chat.headless.HeadlessService
|
||||
@@ -447,6 +448,9 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
var micTesting by remember { mutableStateOf(false) }
|
||||
var micLevel by remember { mutableStateOf(0f) }
|
||||
|
||||
/* Notification sound */
|
||||
var soundEnabled by remember { mutableStateOf(provider.getSoundEnabled()) }
|
||||
|
||||
LaunchedEffect(micTesting) {
|
||||
if (micTesting) {
|
||||
while (true) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) }
|
||||
@@ -486,6 +490,19 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
|
||||
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
/* ── Notification sound ── */
|
||||
Text("Notification sound", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Play sound on incoming message", modifier = Modifier.weight(1f))
|
||||
Switch(checked = soundEnabled, onCheckedChange = {
|
||||
soundEnabled = it
|
||||
NotificationSound.enabled = it
|
||||
scope.launch { provider.setSoundEnabled(it) }
|
||||
})
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 4.dp))
|
||||
|
||||
/* ── Microphone test ── */
|
||||
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
@@ -203,6 +203,11 @@ class ChatViewModel : ViewModel() {
|
||||
val chLen = data[0].toInt() and 0xFF
|
||||
if (data.size >= 1 + chLen) {
|
||||
val chId = String(data, 1, chLen)
|
||||
if (data.size >= 1 + chLen + 8) {
|
||||
val author = java.nio.ByteBuffer.wrap(data, 1 + chLen, 8)
|
||||
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
|
||||
if (author != NativeLib.getMyNodeId()) NotificationSound.play(chId)
|
||||
}
|
||||
val cur = _currentChannel.value
|
||||
if (cur != null && cur.id == chId) refreshMessages(chId)
|
||||
refreshChannels()
|
||||
|
||||
Binary file not shown.
Reference in new issue
Block a user