@ -6,16 +6,13 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
@ -23,6 +20,7 @@ import com.utun.chat.data.NativeLib
import com.utun.chat.data.ServerEntry
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
@ -43,12 +41,57 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
val scope = rememberCoroutineScope ( )
var dirty by remember { mutableStateOf ( false ) }
var tabIndex by remember { mutableStateOf ( 0 ) }
val tabs = listOf ( " Settings " , " Audio " )
val handleBack = {
if ( dirty ) {
scope . launch ( Dispatchers . IO ) {
val path = provider . getString ( " db.path " ) ?: " utun_chat.db "
val port = provider . getInt ( " control.port " )
val configText = provider . buildConfigText ( )
NativeLib . restart ( path , port , configText )
}
}
onBack ( )
}
Scaffold (
topBar = {
TopAppBar (
title = { Text ( " Settings " ) } ,
navigationIcon = { IconButton ( onClick = handleBack ) { Icon ( Icons . AutoMirrored . Filled . ArrowBack , " Back " ) } }
)
}
) { padding ->
Column ( modifier = Modifier . padding ( padding ) ) {
TabRow ( selectedTabIndex = tabIndex ) {
tabs . forEachIndexed { idx , name ->
Tab ( selected = tabIndex == idx , onClick = { tabIndex = idx } , text = { Text ( name ) } )
}
}
when ( tabIndex ) {
0 -> GeneralSettingsTab ( vm , dirty , onDirty = { dirty = it } , onExit )
1 -> AudioSettingsTab ( vm )
}
}
}
}
/* ── General Settings Tab ── */
@OptIn ( ExperimentalMaterial3Api :: class )
@Composable
private fun GeneralSettingsTab ( vm : ChatViewModel , dirty : Boolean , onDirty : ( Boolean ) -> Unit ,
onExit : ( ) -> Unit ) {
val provider = ChatApplication . instance . configProvider
val scope = rememberCoroutineScope ( )
var nodeName by remember { mutableStateOf ( " " ) }
var listenPort by remember { mutableStateOf ( " 12345 " ) }
var controlPort by remember { mutableStateOf ( " 9999 " ) }
var controlBindIp by remember { mutableStateOf ( " 127.0.0.1 " ) }
var ctrlBindLabel by remember { mutableStateOf ( " lo (127.0.0.1) " ) }
var dbPath by remember { mutableStateOf ( " " ) }
var debugLevel by remember { mutableStateOf ( " info " ) }
var logFile by remember { mutableStateOf ( " " ) }
var logUdpIp by remember { mutableStateOf ( " " ) }
@ -61,7 +104,6 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
var showClearDb by remember { mutableStateOf ( false ) }
var clearingDb by remember { mutableStateOf ( false ) }
var catLevels by remember { mutableStateOf < Map < String , String > > ( emptyMap ( ) ) }
var servers by remember { mutableStateOf < List < ServerEntry > > ( emptyList ( ) ) }
var availableIfaces by remember { mutableStateOf < List < Pair < String , String > > > ( emptyList ( ) ) }
@ -72,406 +114,340 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
controlBindIp = provider . getString ( " control.bind_ip " ) ?: " 127.0.0.1 "
val allIface = listOf ( " lo " to " 127.0.0.1 " ) + provider . getAvailableInterfaces ( )
ctrlBindLabel = allIface . firstOrNull { it . second == controlBindIp } ?. let { " ${it.first} ( ${it.second} ) " } ?: " lo (127.0.0.1) "
dbPath = provider . getString ( " db.path " ) ?: " utun_chat.db "
debugLevel = provider . getString ( " debug.console_level " ) ?: " info "
logFile = provider . getString ( " debug.log_file " ) ?: " utun.log "
logUdpIp = provider . getString ( " log_udp.ip " ) ?: " 192.168.40.250 "
logUdpPort = provider . getInt ( " log_udp.port " ) . toString ( ) . let { if ( it == " 0 " ) " 9999 " else it }
val port = logUdpPort . toIntOrNull ( ) ?: 0
if ( logUdpIp . isNotEmpty ( ) && port > 0 ) {
NativeLib . setUdpLogTarget ( logUdpIp , port )
}
if ( logUdpIp . isNotEmpty ( ) && port > 0 ) NativeLib . setUdpLogTarget ( logUdpIp , port )
val stored = provider . getString ( " debug.categories " ) ?: " "
catLevels = if ( stored . isNotEmpty ( ) ) {
stored . split ( " , " ) . associate { pair ->
val ( k , v ) = pair . split ( " = " , limit = 2 )
k to v
}
} else emptyMap ( )
catLevels = if ( stored . isNotEmpty ( ) ) stored . split ( " , " ) . associate { it . split ( " = " , limit = 2 ) . let { ( k , v ) -> k to v } } else emptyMap ( )
servers = provider . loadServers ( )
availableIfaces = provider . getAvailableInterfaces ( )
}
val save = { s : List < ServerEntry > ->
dirty = true
servers = s
scope . launch { provider . saveServers ( s ) }
}
val save = { s : List < ServerEntry > -> onDirty ( true ) ; servers = s ; scope . launch { provider . saveServers ( s ) } }
val setDebug = { level : String ->
dirty = true
NativeLib . setDebugLevel ( " " , level )
scope . launch { provider . setValue ( " debug.console_level " , level ) }
debugLevel = level
onDirty ( true ) ; NativeLib . setDebugLevel ( " " , level )
scope . launch { provider . setValue ( " debug.console_level " , level ) } ; debugLevel = level
}
val setCatLevel = { cat : String , lvl : String ->
dirty = true
onDirty ( true )
val updated = catLevels . toMutableMap ( )
if ( lvl == " " ) updated . remove ( cat ) else updated [ cat ] = lvl
catLevels = updated
val csv = updated . entries . joinToString ( " , " ) { " ${it.key} = ${it.value} " }
scope . launch { provider . setValue ( " debug.categories " , csv ) }
if ( lvl . isEmpty ( ) ) NativeLib . setDebugLevel ( cat , " NONE " )
else NativeLib . setDebugLevel ( cat , lvl )
if ( lvl . isEmpty ( ) ) NativeLib . setDebugLevel ( cat , " NONE " ) else NativeLib . setDebugLevel ( cat , lvl )
}
val handleBack = {
if ( dirty ) {
scope . launch ( Dispatchers . IO ) {
val path = provider . getString ( " db.path " ) ?: " utun_chat.db "
val port = provider . getInt ( " control.port " )
val configText = provider . buildConfigText ( )
NativeLib . restart ( path , port , configText )
}
}
onBack ( )
}
Column (
modifier = Modifier . padding ( 16. dp ) . verticalScroll ( rememberScrollState ( ) ) ,
verticalArrangement = Arrangement . spacedBy ( 12. dp )
) {
/* Node */
Text ( " Node " , style = MaterialTheme . typography . titleMedium )
OutlinedTextField ( nodeName , { v -> onDirty ( true ) ; nodeName = v ; scope . launch { provider . setValue ( " node.name " , v ) } } ,
label = { Text ( " Node Name " ) } , singleLine = true , modifier = Modifier . heightIn ( min = 40. dp ) )
OutlinedTextField ( listenPort , { v -> onDirty ( true ) ; listenPort = v ; scope . launch { provider . setValue ( " node.listen_port " , v ) } } ,
label = { Text ( " Listen Port " ) } , keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number ) ,
singleLine = true , modifier = Modifier . heightIn ( min = 40. dp ) )
Scaffold (
topBar = {
TopAppBar (
title = { Text ( " Settings " ) } ,
navigationIcon = { IconButton ( onClick = handleBack ) { Icon ( Icons . Default . ArrowBack , " Back " ) } }
)
}
) { padding ->
Column (
modifier = Modifier . padding ( padding ) . padding ( 16. dp ) . verticalScroll ( rememberScrollState ( ) ) ,
verticalArrangement = Arrangement . spacedBy ( 12. dp )
) {
Text ( " Node " , style = MaterialTheme . typography . titleMedium )
OutlinedTextField ( nodeName , { v -> dirty = true ; nodeName = v ; scope . launch { provider . setValue ( " node.name " , v ) } } ,
label = { Text ( " Node Name " ) } , singleLine = true ,
modifier = Modifier . heightIn ( min = 40. dp ) )
OutlinedTextField ( listenPort , { v -> dirty = true ; listenPort = v ; scope . launch { provider . setValue ( " node.listen_port " , v ) } } ,
label = { Text ( " Listen Port " ) } ,
modifier = Modifier . heightIn ( min = 40. dp ) ,
keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number ) , singleLine = true )
// Control socket interface (including lo0)
var ctrlExpanded by remember { mutableStateOf ( false ) }
var ctrlBindLabel by remember { mutableStateOf ( " lo (127.0.0.1) " ) }
val ctrlIfaces = remember { listOf ( " lo " to " 127.0.0.1 " ) + provider . getAvailableInterfaces ( ) }
Row ( verticalAlignment = androidx . compose . ui . Alignment . CenterVertically ) {
ExposedDropdownMenuBox ( expanded = ctrlExpanded , onExpandedChange = { ctrlExpanded = it } ,
modifier = Modifier . weight ( 1f ) ) {
OutlinedTextField (
value = ctrlBindLabel , onValueChange = { } ,
readOnly = true , singleLine = true , textStyle = MaterialTheme . typography . bodyLarge ,
label = { Text ( " Control Bind IP " ) } ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( ctrlExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp )
)
ExposedDropdownMenu ( ctrlExpanded , onDismissRequest = { ctrlExpanded = false } ) {
ctrlIfaces . forEach { ( name , ip ) ->
val label = " $name ( $ip ) "
DropdownMenuItem ( text = { Text ( label ) } , onClick = {
controlBindIp = ip
ctrlBindLabel = label
dirty = true
scope . launch { provider . setValue ( " control.bind_ip " , ip ) }
ctrlExpanded = false
} )
}
/* Control interface */
var ctrlExpanded by remember { mutableStateOf ( false ) }
val ctrlIfaces = remember { listOf ( " lo " to " 127.0.0.1 " ) + provider . getAvailableInterfaces ( ) }
Row ( verticalAlignment = Alignment . CenterVertically ) {
ExposedDropdownMenuBox ( expanded = ctrlExpanded , onExpandedChange = { ctrlExpanded = it } , modifier = Modifier . weight ( 1f ) ) {
OutlinedTextField ( ctrlBindLabel , { } , readOnly = true , singleLine = true ,
label = { Text ( " Control Bind IP " ) } ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( ctrlExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp ) )
ExposedDropdownMenu ( ctrlExpanded , onDismissRequest = { ctrlExpanded = false } ) {
ctrlIfaces . forEach { ( n , ip ) ->
DropdownMenuItem ( text = { Text ( " $n ( $ip ) " ) } , onClick = {
controlBindIp = ip ; ctrlBindLabel = " $n ( $ip ) " ; onDirty ( true )
scope . launch { provider . setValue ( " control.bind_ip " , ip ) } ; ctrlExpanded = false
} )
}
}
Spacer ( Modifier . width ( 8. dp ) )
OutlinedTextField ( controlPort , { v -> dirty = true ; controlPort = v ; scope . launch { provider . setValue ( " control.port " , v ) } } ,
label = { Text ( " Port " ) } ,
modifier = Modifier . width ( 80. dp ) . heightIn ( min = 40. dp ) ,
keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number ) , singleLine = true )
}
Spacer ( Modifier . width ( 8. dp ) )
OutlinedTextField ( controlPort , { v -> onDirty ( true ) ; controlPort = v ; scope . launch { provider . setValue ( " control.port " , v ) } } ,
label = { Text ( " Port " ) } , modifier = Modifier . width ( 80. dp ) . heightIn ( min = 40. dp ) ,
keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number ) , singleLine = true )
}
Text ( " Keys " , style = MaterialTheme . typography . titleMedium )
OutlinedTextField ( pubKey , { } , label = { Text ( " Public Key " ) } , readOnly = true , singleLine = true )
Row ( horizontalArrangement = Arrangement . spacedBy ( 8. dp ) ) {
Button ( onClick = {
if ( pubKey . length == 64 ) showOverwriteKeys = true
else { dirty = true ; vm . requestRegenerateKeys ( ) }
} , enabled = ! generatingKeys ) {
Text ( if ( generatingKeys ) " Generating... " else " Generate Keys " )
}
Button ( onClick = { showClearDb = true } ,
colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . error ) ) {
Icon ( Icons . Default . Delete , null , modifier = Modifier . size ( 18. dp ) )
Spacer ( Modifier . width ( 4. dp ) )
Text ( " Clear DB " )
}
/* Keys */
Text ( " Keys " , style = MaterialTheme . typography . titleMedium )
OutlinedTextField ( pubKey , { } , label = { Text ( " Public Key " ) } , readOnly = true , singleLine = true )
Row ( horizontalArrangement = Arrangement . spacedBy ( 8. dp ) ) {
Button ( onClick = { if ( pubKey . length == 64 ) showOverwriteKeys = true else { onDirty ( true ) ; vm . requestRegenerateKeys ( ) } } ,
enabled = ! generatingKeys ) { Text ( if ( generatingKeys ) " Generating... " else " Generate Keys " ) }
Button ( onClick = { showClearDb = true } ,
colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . error ) ) {
Icon ( Icons . Default . Delete , null , modifier = Modifier . size ( 18. dp ) ) ; Spacer ( Modifier . width ( 4. dp ) ) ; Text ( " Clear DB " )
}
}
HorizontalDivider ( Modifier . padding ( vertical = 8. dp ) )
// ── Network Interfaces ──
Text ( " Network Interfaces " , style = MaterialTheme . typography . titleMedium )
servers . forEachIndexed { idx , srv ->
Column ( modifier = Modifier . fillMaxWidth ( ) ) {
// Line 1: [ifName ▾] IP [✕]
Row ( verticalAlignment = androidx . compose . ui . Alignment . CenterVertically ) {
var ifExpanded by remember { mutableStateOf ( false ) }
ExposedDropdownMenuBox ( expanded = ifExpanded , onExpandedChange = { ifExpanded = it } ,
modifier = Modifier . weight ( 1f ) ) {
OutlinedTextField (
value = srv . ifName , onValueChange = { } ,
readOnly = true , singleLine = true ,
textStyle = MaterialTheme . typography . bodyLarge ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( ifExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp )
)
ExposedDropdownMenu ( ifExpanded , onDismissRequest = { ifExpanded = false } ) {
availableIfaces . forEach { ( name , ip ) ->
DropdownMenuItem ( text = { Text ( " $name ( $ip ) " ) } , onClick = {
save ( servers . toMutableList ( ) . also { it [ idx ] = it [ idx ] . copy ( ifName = name , bindIp = ip ) } )
ifExpanded = false
} )
}
HorizontalDivider ( Modifier . padding ( vertical = 8. dp ) )
/* Network Interfaces */
Text ( " Network Interfaces " , style = MaterialTheme . typography . titleMedium )
servers . forEachIndexed { idx , srv ->
Column ( modifier = Modifier . fillMaxWidth ( ) ) {
Row ( verticalAlignment = Alignment . CenterVertically ) {
var ifExpanded by remember { mutableStateOf ( false ) }
ExposedDropdownMenuBox ( expanded = ifExpanded , onExpandedChange = { ifExpanded = it } , modifier = Modifier . weight ( 1f ) ) {
OutlinedTextField ( srv . ifName , { } , readOnly = true , singleLine = true ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( ifExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp ) )
ExposedDropdownMenu ( ifExpanded , onDismissRequest = { ifExpanded = false } ) {
availableIfaces . forEach { ( n , ip ) ->
DropdownMenuItem ( text = { Text ( " $n ( $ip ) " ) } , onClick = {
save ( servers . toMutableList ( ) . also { it [ idx ] = it [ idx ] . copy ( ifName = n , bindIp = ip ) } ) ; ifExpanded = false
} )
}
}
Spacer ( Modifier . width ( 4. dp ) )
Text ( srv . bindIp ,
color = MaterialTheme . colorScheme . onSurfaceVariant ,
style = MaterialTheme . typography . bodySmall ,
maxLines = 1 ,
modifier = Modifier . width ( 105. dp ) )
IconButton ( onClick = {
save ( servers . toMutableList ( ) . also { it . removeAt ( idx ) } )
} , modifier = Modifier . size ( 28. dp ) ) {
Icon ( Icons . Default . Close , " Remove " , modifier = Modifier . size ( 16. dp ) ,
tint = MaterialTheme . colorScheme . error )
}
}
// Line 2: [TCP ▾] port
Row ( verticalAlignment = androidx . compose . ui . Alignment . CenterVertically ) {
var protoExpanded by remember { mutableStateOf ( false ) }
ExposedDropdownMenuBox ( expanded = protoExpanded , onExpandedChange = { protoExpanded = it } ,
modifier = Modifier . width ( 100. dp ) ) {
OutlinedTextField (
value = srv . protocol . uppercase ( ) , onValueChange = { } ,
readOnly = true , singleLine = true ,
textStyle = MaterialTheme . typography . bodyLarge ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( protoExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp )
)
ExposedDropdownMenu ( protoExpanded , onDismissRequest = { protoExpanded = false } ) {
PROTOCOLS . forEach { p ->
DropdownMenuItem ( text = { Text ( p . uppercase ( ) ) } , onClick = {
save ( servers . toMutableList ( ) . also { it [ idx ] = srv . copy ( protocol = p ) } )
protoExpanded = false
} )
}
Spacer ( Modifier . width ( 4. dp ) )
Text ( srv . bindIp , color = MaterialTheme . colorScheme . onSurfaceVariant , style = MaterialTheme . typography . bodySmall ,
maxLines = 1 , modifier = Modifier . width ( 105. dp ) )
IconButton ( onClick = { save ( servers . toMutableList ( ) . also { it . removeAt ( idx ) } ) } , modifier = Modifier . size ( 28. dp ) ) {
Icon ( Icons . Default . Close , " Remove " , modifier = Modifier . size ( 16. dp ) , tint = MaterialTheme . colorScheme . error )
}
}
Row ( verticalAlignment = Alignment . CenterVertically ) {
var protoExpanded by remember { mutableStateOf ( false ) }
ExposedDropdownMenuBox ( expanded = protoExpanded , onExpandedChange = { protoExpanded = it } , modifier = Modifier . width ( 100. dp ) ) {
OutlinedTextField ( srv . protocol . uppercase ( ) , { } , readOnly = true , singleLine = true ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( protoExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp ) )
ExposedDropdownMenu ( protoExpanded , onDismissRequest = { protoExpanded = false } ) {
PROTOCOLS . forEach { p ->
DropdownMenuItem ( text = { Text ( p . uppercase ( ) ) } , onClick = {
save ( servers . toMutableList ( ) . also { it [ idx ] = srv . copy ( protocol = p ) } ) ; protoExpanded = false
} )
}
}
Spacer ( Modifier . width ( 4. dp ) )
var portText by remember ( srv . id ) { mutableStateOf ( srv . port . toString ( ) ) }
OutlinedTextField (
value = portText , singleLine = true ,
textStyle = MaterialTheme . typography . bodyLarge ,
onValueChange = { v ->
portText = v
val p = v . toIntOrNull ( )
if ( p != null && p in 1. . 65535 )
save ( servers . toMutableList ( ) . also { it [ idx ] = srv . copy ( port = p ) } )
} ,
modifier = Modifier . width ( 80. dp ) . heightIn ( min = 40. dp ) ,
keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number )
)
}
Spacer ( Modifier . width ( 4. dp ) )
var portText by remember ( srv . id ) { mutableStateOf ( srv . port . toString ( ) ) }
OutlinedTextField ( portText , { v -> portText = v ; val p = v . toIntOrNull ( ) ; if ( p != null && p in 1. . 65535 )
save ( servers . toMutableList ( ) . also { it [ idx ] = srv . copy ( port = p ) } ) } ,
singleLine = true , modifier = Modifier . width ( 80. dp ) . heightIn ( min = 40. dp ) ,
keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number ) )
}
Spacer ( Modifier . height ( 2. dp ) )
}
// Add button
TextButton (
onClick = {
val iface = availableIfaces . firstOrNull ( ) ?: ( " lo " to " 127.0.0.1 " )
val rndPort = ( 2000. . 9999 ) . random ( )
save ( servers + ServerEntry ( UUID . randomUUID ( ) . toString ( ) , iface . first , iface . second , " tcp " , rndPort ) )
} ,
modifier = Modifier . fillMaxWidth ( )
) {
Icon ( Icons . Default . Add , null , modifier = Modifier . size ( 18. dp ) )
Spacer ( Modifier . width ( 4. dp ) )
Text ( " Add interface " )
}
Spacer ( Modifier . height ( 2. dp ) )
}
TextButton ( onClick = {
val iface = availableIfaces . firstOrNull ( ) ?: ( " lo " to " 127.0.0.1 " )
val rndPort = ( 2000. . 9999 ) . random ( )
save ( servers + ServerEntry ( UUID . randomUUID ( ) . toString ( ) , iface . first , iface . second , " tcp " , rndPort ) )
} , modifier = Modifier . fillMaxWidth ( ) ) { Icon ( Icons . Default . Add , null , modifier = Modifier . size ( 18. dp ) ) ; Spacer ( Modifier . width ( 4. dp ) ) ; Text ( " Add interface " ) }
HorizontalDivider ( Modifier . padding ( vertical = 8. dp ) )
// ── Voice Messages ──
Text ( " Voice Messages " , style = MaterialTheme . typography . titleMedium )
val voicePreset by remember { mutableStateOf ( vm . getVoicePreset ( ) ) }
var presetExpanded by remember { mutableStateOf ( false ) }
val presets = listOf ( " Low (16 kbps) " , " Standard (32 kbps) " , " High (64 kbps) " )
ExposedDropdownMenuBox ( expanded = presetExpanded , onExpandedChange = { presetExpanded = it } ) {
OutlinedTextField (
value = presets [ voicePreset ] , onValueChange = { } ,
readOnly = true , singleLine = true , label = { Text ( " Opus Preset " ) } ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( presetExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp )
)
ExposedDropdownMenu ( presetExpanded , onDismissRequest = { presetExpanded = false } ) {
presets . forEachIndexed { i , name ->
DropdownMenuItem ( text = { Text ( name ) } , onClick = {
vm . setVoicePreset ( i ) ; presetExpanded = false
} )
}
}
}
val compressorEnabled = remember { mutableStateOf ( vm . getVoiceCompressor ( ) ) }
Row ( verticalAlignment = androidx . compose . ui . Alignment . CenterVertically ) {
Text ( " Compressor " , modifier = Modifier . weight ( 1f ) )
Switch (
checked = compressorEnabled . value ,
onCheckedChange = { v -> compressorEnabled . value = v ; vm . setVoiceCompressor ( v ) }
)
}
HorizontalDivider ( Modifier . padding ( vertical = 8. dp ) )
HorizontalDivider ( Modifier . padding ( vertical = 8. dp ) )
// ── Debug ──
Text ( " Debug " , style = MaterialTheme . typography . titleMedium )
DebugLevelDropdown ( label = " Global Level " , current = debugLevel . uppercase ( ) , onSelect = setDebug )
OutlinedTextField ( logFile , { v -> logFile = v ; scope . launch { provider . setValue ( " debug.log_file " , v ) } } ,
label = { Text ( " Log File " ) } , singleLine = true )
Row ( horizontalArrangement = Arrangement . spacedBy ( 8. dp ) ) {
OutlinedTextField ( logUdpIp , { v ->
logUdpIp = v
scope . launch { provider . setValue ( " log_udp.ip " , v ) }
val port = logUdpPort . toIntOrNull ( ) ?: 0
if ( v . isNotEmpty ( ) && port > 0 ) NativeLib . setUdpLogTarget ( v , port )
else NativeLib . setUdpLogTarget ( " " , 0 )
} ,
label = { Text ( " UDP Log IP " ) } ,
modifier = Modifier . weight ( 1f ) . heightIn ( min = 40. dp ) ,
singleLine = true )
OutlinedTextField ( logUdpPort , { v ->
logUdpPort = v
val port = v . toIntOrNull ( ) ?: 0
scope . launch { provider . setValue ( " log_udp.port " , v ) }
if ( logUdpIp . isNotEmpty ( ) && port > 0 && port <= 65535 )
NativeLib . setUdpLogTarget ( logUdpIp , port )
else
NativeLib . setUdpLogTarget ( " " , 0 )
} ,
label = { Text ( " Port " ) } ,
modifier = Modifier . width ( 80. dp ) . heightIn ( min = 40. dp ) ,
keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number ) ,
singleLine = true )
/* Debug */
Text ( " Debug " , style = MaterialTheme . typography . titleMedium )
DebugLevelDropdown ( label = " Global Level " , current = debugLevel . uppercase ( ) , onSelect = setDebug )
OutlinedTextField ( logFile , { v -> logFile = v ; scope . launch { provider . setValue ( " debug.log_file " , v ) } } ,
label = { Text ( " Log File " ) } , singleLine = true )
Row ( horizontalArrangement = Arrangement . spacedBy ( 8. dp ) ) {
OutlinedTextField ( logUdpIp , { v ->
logUdpIp = v ; scope . launch { provider . setValue ( " log_udp.ip " , v ) }
val p = logUdpPort . toIntOrNull ( ) ?: 0
if ( v . isNotEmpty ( ) && p > 0 ) NativeLib . setUdpLogTarget ( v , p ) else NativeLib . setUdpLogTarget ( " " , 0 )
} , label = { Text ( " UDP Log IP " ) } , modifier = Modifier . weight ( 1f ) . heightIn ( min = 40. dp ) , singleLine = true )
OutlinedTextField ( logUdpPort , { v ->
logUdpPort = v ; val p = v . toIntOrNull ( ) ?: 0 ; scope . launch { provider . setValue ( " log_udp.port " , v ) }
if ( logUdpIp . isNotEmpty ( ) && p > 0 && p <= 65535 ) NativeLib . setUdpLogTarget ( logUdpIp , p ) else NativeLib . setUdpLogTarget ( " " , 0 )
} , label = { Text ( " Port " ) } , modifier = Modifier . width ( 80. dp ) . heightIn ( min = 40. dp ) ,
keyboardOptions = KeyboardOptions ( keyboardType = KeyboardType . Number ) , singleLine = true )
}
Row ( modifier = Modifier . fillMaxWidth ( ) . clickable { showDebugCategories = ! showDebugCategories } ,
horizontalArrangement = Arrangement . SpaceBetween ) {
Text ( " Categories " , style = MaterialTheme . typography . titleMedium )
Icon ( Icons . Default . ArrowDropDown , if ( showDebugCategories ) " Collapse " else " Expand " , modifier = Modifier . padding ( end = 4. dp ) )
}
if ( showDebugCategories ) {
CATEGORIES . forEach { cat ->
val current = catLevels [ cat ] ?: " "
DebugLevelDropdown ( label = cat , current = current . ifEmpty { " (global) " } ,
onSelect = { setCatLevel ( cat , if ( it == " NONE " ) " " else it ) } , compact = true )
}
}
Spacer ( Modifier . height ( 16. dp ) )
Row ( modifier = Modifier . fillMaxWidth ( ) . clickable { showDebugCategories = ! showDebugCategories } ,
horizontalArrangement = Arrangement . SpaceBetween ) {
Text ( " Categories " , style = MaterialTheme . typography . titleMedium )
Icon ( Icons . Default . ArrowDropDown ,
contentDescription = if ( showDebugCategories ) " Collapse " else " Expand " ,
modifier = Modifier . padding ( end = 4. dp ) )
}
if ( showDebugCategories ) {
CATEGORIES . forEach { cat ->
val current = catLevels [ cat ] ?: " "
DebugLevelDropdown ( label = cat , current = current . ifEmpty { " (global) " } ,
onSelect = { setCatLevel ( cat , if ( it == " NONE " ) " " else it ) } , compact = true )
}
}
Spacer ( Modifier . height ( 16. dp ) )
Button (
onClick = {
scope . launch ( Dispatchers . IO ) {
val path = provider . getString ( " db.path " ) ?: " "
val port = provider . getInt ( " control.port " )
val configText = provider . buildConfigText ( )
NativeLib . restart ( path , port , configText )
}
} ,
modifier = Modifier . fillMaxWidth ( ) ,
colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . secondary )
) {
Icon ( Icons . Default . Refresh , null , modifier = Modifier . size ( 18. dp ) )
Spacer ( Modifier . width ( 8. dp ) )
Text ( " Restart instance " )
Button ( onClick = {
scope . launch ( Dispatchers . IO ) {
val path = provider . getString ( " db.path " ) ?: " " ; val port = provider . getInt ( " control.port " )
NativeLib . restart ( path , port , provider . buildConfigText ( ) )
}
} , modifier = Modifier . fillMaxWidth ( ) , colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . secondary ) ) {
Icon ( Icons . Default . Refresh , null , modifier = Modifier . size ( 18. dp ) ) ; Spacer ( Modifier . width ( 8. dp ) ) ; Text ( " Restart instance " )
}
Spacer ( Modifier . height ( 8. dp ) )
Button ( onClick = { NativeLib . stop ( ) ; onExit ( ) } , modifier = Modifier . fillMaxWidth ( ) ,
colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . error ) ) {
Icon ( Icons . Default . PowerSettingsNew , null , modifier = Modifier . size ( 18. dp ) ) ; Spacer ( Modifier . width ( 8. dp ) ) ; Text ( " Stop All & Exit " )
}
Spacer ( Modifier . height ( 16. dp ) )
}
/* Dialogs */
if ( showOverwriteKeys ) AlertDialog (
onDismissRequest = { showOverwriteKeys = false } , title = { Text ( " Overwrite Keys? " ) } ,
text = { Text ( " Keys already exist. Generating new keys will invalidate all existing connections and channels. " ) } ,
confirmButton = { Button ( onClick = { showOverwriteKeys = false ; onDirty ( true ) ; vm . requestRegenerateKeys ( ) } ) { Text ( " Overwrite " ) } } ,
dismissButton = { TextButton ( onClick = { showOverwriteKeys = false } ) { Text ( " Cancel " ) } } )
Spacer ( Modifier . height ( 8. dp ) )
Button (
onClick = {
NativeLib . stop ( )
onExit ( )
} ,
modifier = Modifier . fillMaxWidth ( ) ,
colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . error )
) {
Icon ( Icons . Default . PowerSettingsNew , null , modifier = Modifier . size ( 18. dp ) )
Spacer ( Modifier . width ( 8. dp ) )
Text ( " Stop All & Exit " )
if ( showClearDb ) AlertDialog (
onDismissRequest = { showClearDb = false } , title = { Text ( " Clear Database? " ) } ,
text = { Text ( " This will delete all channels, messages, and peers. \n This cannot be undone. " ) } ,
confirmButton = { Button ( onClick = {
showClearDb = false ; clearingDb = true
scope . launch ( Dispatchers . IO ) {
vm . clearState ( ) ; NativeLib . stop ( ) ; NativeLib . clearDatabase ( )
val path = provider . getString ( " db.path " ) ?: " " ; val port = provider . getInt ( " control.port " )
NativeLib . restart ( path , port , provider . buildConfigText ( ) )
withContext ( Dispatchers . Main ) { clearingDb = false }
}
} , enabled = ! clearingDb , colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . error ) ) { Text ( " Delete All " ) } } ,
dismissButton = { TextButton ( onClick = { showClearDb = false } ) { Text ( " Cancel " ) } } )
}
/* ── Audio Settings Tab ── */
@OptIn ( ExperimentalMaterial3Api :: class )
@Composable
private fun AudioSettingsTab ( vm : ChatViewModel ) {
val scope = rememberCoroutineScope ( )
Spacer ( Modifier . height ( 16. dp ) )
/* Mic test state */
var micTesting by remember { mutableStateOf ( false ) }
var micLevel by remember { mutableStateOf ( 0f ) }
LaunchedEffect ( micTesting ) {
if ( micTesting ) {
while ( true ) { micLevel = NativeLib . voiceGetPeakLevel ( ) ; delay ( 25 ) }
}
}
if ( showOverwriteKeys ) {
AlertDialog (
onDismissRequest = { showOverwriteKeys = false } ,
title = { Text ( " Overwrite Keys? " ) } ,
text = { Text ( " Keys already exist. Generating new keys will invalidate all existing connections and channels. " ) } ,
confirmButton = {
Button ( onClick = {
showOverwriteKeys = false
dirty = true
vm . requestRegenerateKeys ( )
} ) { Text ( " Overwrite " ) }
} ,
dismissButton = {
TextButton ( onClick = { showOverwriteKeys = false } ) { Text ( " Cancel " ) }
}
)
/* Codec preset */
val presets = listOf ( " Low (16 kbps) " , " Standard (32 kbps) " , " High (64 kbps) " )
var preset by remember { mutableStateOf ( vm . getVoicePreset ( ) ) }
var presetExpanded by remember { mutableStateOf ( false ) }
/* Compressor params with defaults from desktop */
var compressorOn by remember { mutableStateOf ( vm . getVoiceCompressor ( ) ) }
var maxGainDb by remember { mutableStateOf ( 30 ) }
var lookbackMs by remember { mutableStateOf ( 200 ) }
var lookaheadMs by remember { mutableStateOf ( 100 ) }
var riseRateTenths by remember { mutableStateOf ( 20 ) }
var targetLevelDb by remember { mutableStateOf ( - 12 ) }
val apply = {
vm . setVoicePreset ( preset )
vm . setVoiceCompressor ( compressorOn )
vm . setCompressorConfig ( maxGainDb , lookbackMs , lookaheadMs , riseRateTenths / 10f , targetLevelDb . toFloat ( ) )
}
if ( showClearDb ) {
AlertDialog (
onDismissRequest = { showClearDb = false } ,
title = { Text ( " Clear Database? " ) } ,
text = { Text ( " This will delete all channels, messages, and peers. \n This cannot be undone. " ) } ,
confirmButton = {
Button ( onClick = {
showClearDb = false
clearingDb = true
scope . launch ( Dispatchers . IO ) {
vm . clearState ( )
NativeLib . stop ( )
NativeLib . clearDatabase ( )
val path = provider . getString ( " db.path " ) ?: " "
val port = provider . getInt ( " control.port " )
val configText = provider . buildConfigText ( )
NativeLib . restart ( path , port , configText )
withContext ( Dispatchers . Main ) { clearingDb = false }
}
} , enabled = ! clearingDb ,
colors = ButtonDefaults . buttonColors ( containerColor = MaterialTheme . colorScheme . error ) ) {
Text ( " Delete All " )
Column (
modifier = Modifier . padding ( 16. dp ) . verticalScroll ( rememberScrollState ( ) ) ,
verticalArrangement = Arrangement . spacedBy ( 12. dp )
) {
/* ── Microphone test ── */
Text ( " Microphone test " , style = MaterialTheme . typography . titleMedium )
Row ( verticalAlignment = Alignment . CenterVertically , horizontalArrangement = Arrangement . spacedBy ( 8. dp ) ) {
val lvlPct = micLevel . coerceIn ( 0f , 1f )
LinearProgressIndicator (
progress = { lvlPct } ,
modifier = Modifier . weight ( 1f ) . height ( 16. dp ) ,
color = when { lvlPct < 0.6f -> Color ( 0xFF27AE60 ) ; lvlPct < 0.85f -> Color ( 0xFFF1C40F ) ; else -> Color ( 0xFFE53935 ) } ,
trackColor = MaterialTheme . colorScheme . surfaceVariant ,
)
Button ( onClick = {
if ( micTesting ) { vm . audioRecorder . stopMicTest ( ) ; micTesting = false ; micLevel = 0f }
else {
if ( vm . audioRecorder . startMicTest ( ) ) { micTesting = true ; micLevel = 0f }
}
apply ( )
} ) { Text ( if ( micTesting ) " Stop " else " Test " ) }
}
HorizontalDivider ( Modifier . padding ( vertical = 4. dp ) )
/* ── Voice codec quality ── */
Text ( " Voice codec quality " , style = MaterialTheme . typography . titleMedium )
ExposedDropdownMenuBox ( expanded = presetExpanded , onExpandedChange = { presetExpanded = it } ) {
OutlinedTextField ( presets [ preset ] , { } , readOnly = true , singleLine = true ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( presetExpanded ) } ,
modifier = Modifier . menuAnchor ( ) . heightIn ( min = 40. dp ) )
ExposedDropdownMenu ( presetExpanded , onDismissRequest = { presetExpanded = false } ) {
presets . forEachIndexed { i , n ->
DropdownMenuItem ( text = { Text ( n ) } , onClick = { preset = i ; apply ( ) ; presetExpanded = false } )
}
} ,
dismissButton = {
TextButton ( onClick = { showClearDb = false } ) { Text ( " Cancel " ) }
}
}
HorizontalDivider ( Modifier . padding ( vertical = 4. dp ) )
/* ── Voice Compressor ── */
Text ( " Voice Compressor " , style = MaterialTheme . typography . titleMedium )
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text ( " Enable compressor " , modifier = Modifier . weight ( 1f ) )
Switch ( checked = compressorOn , onCheckedChange = { compressorOn = it ; apply ( ) } )
}
LabeledSlider ( " Max gain: " , maxGainDb . toFloat ( ) , 0f .. 30f , 30 , " ${maxGainDb} dB " ,
{ maxGainDb = it . toInt ( ) ; apply ( ) } )
LabeledSlider ( " Lookback time: " , lookbackMs . toFloat ( ) , 50f .. 500f , 45 , " ${lookbackMs} ms " ,
{ lookbackMs = it . toInt ( ) ; apply ( ) } )
LabeledSlider ( " Lookahead time: " , lookaheadMs . toFloat ( ) , 20f .. 500f , 48 , " ${lookaheadMs} ms " ,
{ lookaheadMs = it . toInt ( ) ; apply ( ) } )
LabeledSlider ( " Rise rate (x/500ms): " , riseRateTenths . toFloat ( ) , 11f .. 100f , 89 ,
String . format ( " %.1fx " , riseRateTenths / 10f ) ,
{ riseRateTenths = it . toInt ( ) ; apply ( ) } )
LabeledSlider ( " Target level: " , targetLevelDb . toFloat ( ) , - 40f .. 0f , 40 , " ${targetLevelDb} dBFS " ,
{ targetLevelDb = it . toInt ( ) ; apply ( ) } )
}
}
/* ── Labeled Slider ── */
@Composable
private fun LabeledSlider (
label : String , value : Float , range : ClosedFloatingPointRange < Float > ,
steps : Int , displayValue : String , onValueChange : ( Float ) -> Unit
) {
Column {
Row ( modifier = Modifier . fillMaxWidth ( ) , horizontalArrangement = Arrangement . SpaceBetween ) {
Text ( label , style = MaterialTheme . typography . bodyMedium )
Text ( displayValue , style = MaterialTheme . typography . bodySmall , color = MaterialTheme . colorScheme . onSurfaceVariant )
}
Slider (
value = value , onValueChange = onValueChange ,
valueRange = range , steps = steps ,
modifier = Modifier . fillMaxWidth ( )
)
}
}
/* ── Debug Level Dropdown ── */
@OptIn ( ExperimentalMaterial3Api :: class )
@Composable
private fun DebugLevelDropdown (
label : String , current : String , onSelect : ( String ) -> Unit , compact : Boolean = false
) {
private fun DebugLevelDropdown ( label : String , current : String , onSelect : ( String ) -> Unit , compact : Boolean = false ) {
var expanded by remember { mutableStateOf ( false ) }
if ( compact ) {
Row ( verticalAlignment = androidx . compose . ui . Alignment . CenterVertically ) {
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text ( label , modifier = Modifier . width ( 120. dp ) , style = MaterialTheme . typography . labelMedium )
ExposedDropdownMenuBox ( expanded = expanded , onExpandedChange = { expanded = it } ) {
OutlinedTextField ( value = current , onValueChange = { } , readOnly = true ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( expanded = expanded ) } ,
OutlinedTextField ( current , { } , readOnly = true ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( expanded ) } ,
modifier = Modifier . menuAnchor ( ) . height ( 48. dp ) . weight ( 1f ) , singleLine = true )
ExposedDropdownMenu ( expanded = expanded , onDismissRequest = { expanded = false } ) {
ExposedDropdownMenu ( expanded , onDismissRequest = { expanded = false } ) {
LEVELS . forEach { lvl ->
DropdownMenuItem ( text = { Text ( lvl ) } , onClick = { onSelect ( lvl ) ; expanded = false } )
}
@ -480,11 +456,10 @@ private fun DebugLevelDropdown(
}
} else {
ExposedDropdownMenuBox ( expanded = expanded , onExpandedChange = { expanded = it } ) {
OutlinedTextField ( value = current , onValueChange = { } , readOnly = true ,
label = { Text ( " $label level " ) } ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( expanded = expanded ) } ,
OutlinedTextField ( current , { } , readOnly = true , label = { Text ( " $label level " ) } ,
trailingIcon = { ExposedDropdownMenuDefaults . TrailingIcon ( expanded ) } ,
modifier = Modifier . menuAnchor ( ) , singleLine = true )
ExposedDropdownMenu ( expanded = expanded , onDismissRequest = { expanded = false } ) {
ExposedDropdownMenu ( expanded , onDismissRequest = { expanded = false } ) {
LEVELS . forEach { lvl ->
DropdownMenuItem ( text = { Text ( lvl ) } , onClick = { onSelect ( lvl ) ; expanded = false } )
}