fix: settings save button (back=discard), fix missing alias in featherchat tests

- Settings now uses draft state — changes only persist on explicit Save
- Back button discards unsaved changes
- Added applyServers() for batch server updates
- Added missing alias field to CallOffer in featherchat tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-04-06 04:30:23 +00:00
parent 550a124972
commit 7eb136fcb3
3 changed files with 88 additions and 32 deletions

View File

@@ -149,6 +149,14 @@ class CallViewModel : ViewModel(), WzpCallback {
} }
} }
/** Batch-apply servers and selection from Settings draft state. */
fun applyServers(servers: List<ServerEntry>, selected: Int) {
_servers.value = servers
_selectedServer.value = selected.coerceIn(0, servers.lastIndex)
settings?.saveServers(servers)
settings?.saveSelectedServer(_selectedServer.value)
}
fun setRoomName(name: String) { fun setRoomName(name: String) {
_roomName.value = name _roomName.value = name
settings?.saveRoom(name) settings?.saveRoom(name)

View File

@@ -21,9 +21,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Divider
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Divider
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
@@ -36,9 +36,12 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -47,6 +50,7 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.wzp.ui.call.CallViewModel import com.wzp.ui.call.CallViewModel
import com.wzp.ui.call.ServerEntry
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
@@ -55,14 +59,36 @@ fun SettingsScreen(
onBack: () -> Unit onBack: () -> Unit
) { ) {
val context = LocalContext.current val context = LocalContext.current
val servers by viewModel.servers.collectAsState()
val selectedServer by viewModel.selectedServer.collectAsState() // Snapshot current values into local draft state
val roomName by viewModel.roomName.collectAsState() val currentAlias by viewModel.alias.collectAsState()
val preferIPv6 by viewModel.preferIPv6.collectAsState() val currentSeedHex by viewModel.seedHex.collectAsState()
val playoutGainDb by viewModel.playoutGainDb.collectAsState() val currentServers by viewModel.servers.collectAsState()
val captureGainDb by viewModel.captureGainDb.collectAsState() val currentSelectedServer by viewModel.selectedServer.collectAsState()
val alias by viewModel.alias.collectAsState() val currentRoomName by viewModel.roomName.collectAsState()
val seedHex by viewModel.seedHex.collectAsState() val currentPreferIPv6 by viewModel.preferIPv6.collectAsState()
val currentPlayoutGain by viewModel.playoutGainDb.collectAsState()
val currentCaptureGain by viewModel.captureGainDb.collectAsState()
// Draft state — initialized from current values
var draftAlias by remember { mutableStateOf(currentAlias) }
var draftSeedHex by remember { mutableStateOf(currentSeedHex) }
val draftServers = remember { currentServers.toMutableStateList() }
var draftSelectedServer by remember { mutableIntStateOf(currentSelectedServer) }
var draftRoomName by remember { mutableStateOf(currentRoomName) }
var draftPreferIPv6 by remember { mutableStateOf(currentPreferIPv6) }
var draftPlayoutGain by remember { mutableFloatStateOf(currentPlayoutGain) }
var draftCaptureGain by remember { mutableFloatStateOf(currentCaptureGain) }
// Track if anything changed
val hasChanges = draftAlias != currentAlias ||
draftSeedHex != currentSeedHex ||
draftServers.toList() != currentServers ||
draftSelectedServer != currentSelectedServer ||
draftRoomName != currentRoomName ||
draftPreferIPv6 != currentPreferIPv6 ||
draftPlayoutGain != currentPlayoutGain ||
draftCaptureGain != currentCaptureGain
var showAddServerDialog by remember { mutableStateOf(false) } var showAddServerDialog by remember { mutableStateOf(false) }
var showRestoreKeyDialog by remember { mutableStateOf(false) } var showRestoreKeyDialog by remember { mutableStateOf(false) }
@@ -94,8 +120,23 @@ fun SettingsScreen(
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Balance the back button // Save button — only enabled when changes exist
Spacer(modifier = Modifier.width(64.dp)) Button(
onClick = {
viewModel.setAlias(draftAlias)
if (draftSeedHex != currentSeedHex) viewModel.restoreSeed(draftSeedHex)
viewModel.applyServers(draftServers.toList(), draftSelectedServer)
viewModel.setRoomName(draftRoomName)
viewModel.setPreferIPv6(draftPreferIPv6)
viewModel.setPlayoutGainDb(draftPlayoutGain)
viewModel.setCaptureGainDb(draftCaptureGain)
Toast.makeText(context, "Settings saved", Toast.LENGTH_SHORT).show()
onBack()
},
enabled = hasChanges
) {
Text("Save")
}
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -104,8 +145,8 @@ fun SettingsScreen(
SectionHeader("Identity") SectionHeader("Identity")
OutlinedTextField( OutlinedTextField(
value = alias, value = draftAlias,
onValueChange = { viewModel.setAlias(it) }, onValueChange = { draftAlias = it },
label = { Text("Display Name") }, label = { Text("Display Name") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -114,7 +155,7 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
// Fingerprint display // Fingerprint display
val fingerprint = if (seedHex.length >= 16) seedHex.take(16).uppercase() else "Not generated" val fingerprint = if (draftSeedHex.length >= 16) draftSeedHex.take(16).uppercase() else "Not generated"
Text( Text(
text = "Fingerprint", text = "Fingerprint",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -134,7 +175,7 @@ fun SettingsScreen(
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilledTonalButton(onClick = { FilledTonalButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", seedHex)) clipboard.setPrimaryClip(ClipData.newPlainText("WZP Key", draftSeedHex))
Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Key copied to clipboard", Toast.LENGTH_SHORT).show()
}) { }) {
Text("Copy Key") Text("Copy Key")
@@ -153,14 +194,14 @@ fun SettingsScreen(
GainSlider( GainSlider(
label = "Voice Volume", label = "Voice Volume",
gainDb = playoutGainDb, gainDb = draftPlayoutGain,
onGainChange = { viewModel.setPlayoutGainDb(it) } onGainChange = { draftPlayoutGain = Math.round(it).toFloat() }
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
GainSlider( GainSlider(
label = "Mic Gain", label = "Mic Gain",
gainDb = captureGainDb, gainDb = draftCaptureGain,
onGainChange = { viewModel.setCaptureGainDb(it) } onGainChange = { draftCaptureGain = Math.round(it).toFloat() }
) )
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
@@ -175,11 +216,11 @@ fun SettingsScreen(
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
verticalArrangement = Arrangement.spacedBy(4.dp) verticalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
servers.forEachIndexed { idx, entry -> draftServers.forEachIndexed { idx, entry ->
val isSelected = selectedServer == idx val isSelected = draftSelectedServer == idx
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
FilledTonalIconButton( FilledTonalIconButton(
onClick = { viewModel.selectServer(idx) }, onClick = { draftSelectedServer = idx },
modifier = Modifier modifier = Modifier
.padding(end = 2.dp) .padding(end = 2.dp)
.height(36.dp) .height(36.dp)
@@ -203,7 +244,12 @@ fun SettingsScreen(
// Show remove button for non-default servers // Show remove button for non-default servers
if (idx >= 2) { if (idx >= 2) {
TextButton( TextButton(
onClick = { viewModel.removeServer(idx) }, onClick = {
draftServers.removeAt(idx)
if (draftSelectedServer >= draftServers.size) {
draftSelectedServer = 0
}
},
modifier = Modifier.height(36.dp) modifier = Modifier.height(36.dp)
) { ) {
Text("X", color = MaterialTheme.colorScheme.error) Text("X", color = MaterialTheme.colorScheme.error)
@@ -224,7 +270,7 @@ fun SettingsScreen(
// Show selected server address // Show selected server address
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Default: ${servers.getOrNull(selectedServer)?.address ?: "none"}", text = "Default: ${draftServers.getOrNull(draftSelectedServer)?.address ?: "none"}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -246,8 +292,8 @@ fun SettingsScreen(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
Switch( Switch(
checked = preferIPv6, checked = draftPreferIPv6,
onCheckedChange = { viewModel.setPreferIPv6(it) } onCheckedChange = { draftPreferIPv6 = it }
) )
} }
@@ -259,8 +305,8 @@ fun SettingsScreen(
SectionHeader("Room") SectionHeader("Room")
OutlinedTextField( OutlinedTextField(
value = roomName, value = draftRoomName,
onValueChange = { viewModel.setRoomName(it) }, onValueChange = { draftRoomName = it },
label = { Text("Default Room") }, label = { Text("Default Room") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@@ -274,7 +320,7 @@ fun SettingsScreen(
AddServerDialog( AddServerDialog(
onDismiss = { showAddServerDialog = false }, onDismiss = { showAddServerDialog = false },
onAdd = { host, port, label -> onAdd = { host, port, label ->
viewModel.addServer("$host:$port", label) draftServers.add(ServerEntry("$host:$port", label))
showAddServerDialog = false showAddServerDialog = false
} }
) )
@@ -284,9 +330,9 @@ fun SettingsScreen(
RestoreKeyDialog( RestoreKeyDialog(
onDismiss = { showRestoreKeyDialog = false }, onDismiss = { showRestoreKeyDialog = false },
onRestore = { hex -> onRestore = { hex ->
viewModel.restoreSeed(hex) draftSeedHex = hex
showRestoreKeyDialog = false showRestoreKeyDialog = false
Toast.makeText(context, "Key restored", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Key staged — press Save to apply", Toast.LENGTH_SHORT).show()
} }
) )
} }
@@ -316,7 +362,7 @@ private fun GainSlider(label: String, gainDb: Float, onGainChange: (Float) -> Un
) )
Slider( Slider(
value = gainDb, value = gainDb,
onValueChange = { onGainChange(Math.round(it).toFloat()) }, onValueChange = onGainChange,
valueRange = -20f..20f, valueRange = -20f..20f,
steps = 0, steps = 0,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()

View File

@@ -125,6 +125,7 @@ mod tests {
ephemeral_pub: [2u8; 32], ephemeral_pub: [2u8; 32],
signature: vec![3u8; 64], signature: vec![3u8; 64],
supported_profiles: vec![QualityProfile::GOOD], supported_profiles: vec![QualityProfile::GOOD],
alias: None,
}; };
let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom")); let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom"));
@@ -142,6 +143,7 @@ mod tests {
ephemeral_pub: [0; 32], ephemeral_pub: [0; 32],
signature: vec![], signature: vec![],
supported_profiles: vec![], supported_profiles: vec![],
alias: None,
}; };
assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer)); assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer));