enhancement: better contact detail screen ux

This commit is contained in:
2026-06-13 15:57:46 +02:00
parent ef03a986a4
commit 0aa55710a9
@@ -1,5 +1,6 @@
package com.bitcointxoko.gudariwallet.ui.contacts package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -44,6 +45,9 @@ fun ContactDetailScreen(
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle() val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle() val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
var isEditing by remember { mutableStateOf(false) }
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -68,22 +72,25 @@ fun ContactDetailScreen(
}, },
actions = { actions = {
if (uiState is ContactDetailUiState.Success) { if (uiState is ContactDetailUiState.Success) {
IconButton(onClick = { viewModel.openEditAliasSheet() }) { if (isEditing) {
Icon(Icons.Default.Edit, contentDescription = "Edit name") // Done button exits edit mode
TextButton(onClick = { isEditing = false }) {
Text("Done")
}
} else {
// Edit button opens edit mode
IconButton(onClick = { isEditing = true }) {
Icon(Icons.Default.Edit, contentDescription = "Edit contact")
} }
IconButton(onClick = { viewModel.requestDelete() }) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete contact",
tint = MaterialTheme.colorScheme.error
)
} }
} }
} }
) )
}, },
floatingActionButton = { floatingActionButton = {
if (uiState is ContactDetailUiState.Success) { // FAB only visible in edit mode so users don't accidentally add
// addresses while browsing
if (uiState is ContactDetailUiState.Success && isEditing) {
FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) { FloatingActionButton(onClick = { viewModel.openAddAddressSheet() }) {
Icon(Icons.Default.Add, contentDescription = "Add payment address") Icon(Icons.Default.Add, contentDescription = "Add payment address")
} }
@@ -93,14 +100,18 @@ fun ContactDetailScreen(
when (val state = uiState) { when (val state = uiState) {
is ContactDetailUiState.Loading -> { is ContactDetailUiState.Loading -> {
Box( Box(
Modifier.fillMaxSize().padding(innerPadding), Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { CircularProgressIndicator() } ) { CircularProgressIndicator() }
} }
is ContactDetailUiState.NotFound -> { is ContactDetailUiState.NotFound -> {
Box( Box(
Modifier.fillMaxSize().padding(innerPadding), Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
@@ -113,8 +124,11 @@ fun ContactDetailScreen(
is ContactDetailUiState.Success -> { is ContactDetailUiState.Success -> {
ContactDetailContent( ContactDetailContent(
state = state, state = state,
onRemoveAddress = { viewModel.removeAddress(it) }, isEditing = isEditing,
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
onSetDefault = { viewModel.setDefaultAddress(it) }, onSetDefault = { viewModel.setDefaultAddress(it) },
onEditAlias = { viewModel.openEditAliasSheet() },
onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
) )
} }
@@ -140,6 +154,27 @@ fun ContactDetailScreen(
) )
} }
pendingAddressDelete?.let { addressId ->
AlertDialog(
onDismissRequest = { pendingAddressDelete = null },
title = { Text("Remove address") },
text = { Text("This payment address will be permanently removed from the contact.") },
confirmButton = {
TextButton(onClick = {
viewModel.removeAddress(addressId)
pendingAddressDelete = null
}) {
Text("Remove", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { pendingAddressDelete = null }) {
Text(strings.cancel)
}
}
)
}
// ── Add address sheet ───────────────────────────────────────────────── // ── Add address sheet ─────────────────────────────────────────────────
if (showAddAddressSheet) { if (showAddAddressSheet) {
AddAddressSheet( AddAddressSheet(
@@ -171,8 +206,11 @@ fun ContactDetailScreen(
@Composable @Composable
private fun ContactDetailContent( private fun ContactDetailContent(
state: ContactDetailUiState.Success, state: ContactDetailUiState.Success,
isEditing: Boolean,
onRemoveAddress: (addressId: String) -> Unit, onRemoveAddress: (addressId: String) -> Unit,
onSetDefault: (PaymentAddressEntity) -> Unit, onSetDefault: (PaymentAddressEntity) -> Unit,
onEditAlias: () -> Unit,
onDeleteContact: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val contact = state.data.contact val contact = state.data.contact
@@ -225,31 +263,33 @@ private fun ContactDetailContent(
// ── Payment addresses section ───────────────────────────────────── // ── Payment addresses section ─────────────────────────────────────
item { item {
DetailSection(title = "Payment addresses") {} DetailSection(title = "Payment addresses") {
}
if (addresses.isEmpty()) { if (addresses.isEmpty()) {
item {
Text( Text(
text = "No addresses added yet", text = "No addresses added yet",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) )
}
} else { } else {
items(addresses, key = { it.id }) { addr -> Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
addresses.forEachIndexed { index, addr ->
AddressRow( AddressRow(
address = addr, address = addr,
isEditing = isEditing,
onRemove = { onRemoveAddress(addr.id) }, onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) } onSetDefault = { onSetDefault(addr) }
) )
if (index < addresses.lastIndex) {
HorizontalDivider( HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp), color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp thickness = 0.5.dp
) )
} }
} }
}
}
}
}
// ── Notes / about (nostr-sourced, read-only for now) ───────────── // ── Notes / about (nostr-sourced, read-only for now) ─────────────
contact.about?.let { about -> contact.about?.let { about ->
@@ -259,6 +299,47 @@ private fun ContactDetailContent(
} }
} }
} }
// ── Destructive actions (edit mode only) ─────────────────────────
if (isEditing) {
item {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = onEditAlias,
modifier = Modifier.fillMaxWidth()
) {
Icon(
Icons.Default.Edit,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Edit name")
}
OutlinedButton(
onClick = onDeleteContact,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.error)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(8.dp))
Text("Delete contact")
}
}
}
}
} }
} }
@@ -267,8 +348,9 @@ private fun ContactDetailContent(
@Composable @Composable
private fun AddressRow( private fun AddressRow(
address: PaymentAddressEntity, address: PaymentAddressEntity,
isEditing: Boolean,
onRemove: () -> Unit, onRemove: () -> Unit,
onSetDefault: () -> Unit onSetDefault: () -> Unit,
) { ) {
val typeLabel = when (address.type) { val typeLabel = when (address.type) {
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address" PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
@@ -279,45 +361,94 @@ private fun AddressRow(
PaymentAddressType.BIP353.name -> "BIP-353" PaymentAddressType.BIP353.name -> "BIP-353"
else -> address.type else -> address.type
} }
val label = address.label?.let { "$typeLabel · $it" } ?: typeLabel
DetailRow( Row(
label = label, modifier = Modifier
value = address.address, .fillMaxWidth()
.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// ── Label + value ─────────────────────────────────────────────
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
text = typeLabel,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
address.label?.let { lbl ->
Text(
text = "· $lbl",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (address.isDefault) {
SuggestionChip(
onClick = {},
label = {
Text(
"default",
style = MaterialTheme.typography.labelSmall
)
},
modifier = Modifier.height(20.dp)
)
}
}
Text(
text = address.address,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
trailingContent = { overflow = TextOverflow.Ellipsis
Row { )
// Set default star — filled if already default }
// ── Trailing actions ──────────────────────────────────────────
Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
// Copy always available
CopyIconButton(
label = "Copy address",
value = address.address,
size = 36.dp
)
// Star + delete only in edit mode
if (isEditing) {
IconButton( IconButton(
onClick = onSetDefault, onClick = onSetDefault,
modifier = Modifier.size(32.dp) modifier = Modifier.size(36.dp)
) { ) {
Icon( Icon(
imageVector = if (address.isDefault) Icons.Filled.Star imageVector = if (address.isDefault) Icons.Filled.Star
else Icons.Outlined.Star, else Icons.Outlined.Star,
contentDescription = if (address.isDefault) "Default address" contentDescription = if (address.isDefault) "Default address"
else "Set as default", else "Set as default",
modifier = Modifier.size(16.dp), modifier = Modifier.size(18.dp),
tint = if (address.isDefault) MaterialTheme.colorScheme.primary tint = if (address.isDefault) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
// Remove
IconButton( IconButton(
onClick = onRemove, onClick = onRemove,
modifier = Modifier.size(32.dp) modifier = Modifier.size(36.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = "Remove address", contentDescription = "Remove address",
modifier = Modifier.size(16.dp), modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error tint = MaterialTheme.colorScheme.error
) )
} }
} }
} }
)
} }
}
// ── Add address sheet ────────────────────────────────────────────────────────── // ── Add address sheet ──────────────────────────────────────────────────────────