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:
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user