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 }) {
IconButton(onClick = { viewModel.requestDelete() }) { Text("Done")
Icon( }
Icons.Default.Delete, } else {
contentDescription = "Delete contact", // Edit button opens edit mode
tint = MaterialTheme.colorScheme.error IconButton(onClick = { isEditing = true }) {
) Icon(Icons.Default.Edit, contentDescription = "Edit contact")
}
} }
} }
} }
) )
}, },
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)
) )
} }
@@ -125,8 +139,8 @@ fun ContactDetailScreen(
if (pendingDelete) { if (pendingDelete) {
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.cancelDelete() }, onDismissRequest = { viewModel.cancelDelete() },
title = { Text("Delete contact") }, title = { Text("Delete contact") },
text = { Text("This contact and all their addresses will be permanently removed.") }, text = { Text("This contact and all their addresses will be permanently removed.") },
confirmButton = { confirmButton = {
TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) { TextButton(onClick = { viewModel.confirmDelete(onDeleted = onBack) }) {
Text("Delete", color = MaterialTheme.colorScheme.error) Text("Delete", color = MaterialTheme.colorScheme.error)
@@ -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,29 +263,31 @@ private fun ContactDetailContent(
// ── Payment addresses section ───────────────────────────────────── // ── Payment addresses section ─────────────────────────────────────
item { item {
DetailSection(title = "Payment addresses") {} DetailSection(title = "Payment addresses") {
} if (addresses.isEmpty()) {
Text(
if (addresses.isEmpty()) { text = "No addresses added yet",
item { style = MaterialTheme.typography.bodySmall,
Text( color = MaterialTheme.colorScheme.onSurfaceVariant,
text = "No addresses added yet", )
style = MaterialTheme.typography.bodySmall, } else {
color = MaterialTheme.colorScheme.onSurfaceVariant, Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) addresses.forEachIndexed { index, addr ->
) AddressRow(
} address = addr,
} else { isEditing = isEditing,
items(addresses, key = { it.id }) { addr -> onRemove = { onRemoveAddress(addr.id) },
AddressRow( onSetDefault = { onSetDefault(addr) }
address = addr, )
onRemove = { onRemoveAddress(addr.id) }, if (index < addresses.lastIndex) {
onSetDefault = { onSetDefault(addr) } HorizontalDivider(
) color = MaterialTheme.colorScheme.outlineVariant,
HorizontalDivider( thickness = 0.5.dp
modifier = Modifier.padding(horizontal = 16.dp), )
thickness = 0.5.dp }
) }
}
}
} }
} }
@@ -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,46 +361,95 @@ 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()
maxLines = 1, .padding(vertical = 12.dp),
trailingContent = { verticalAlignment = Alignment.CenterVertically
Row { ) {
// Set default star — filled if already default // ── 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,
overflow = TextOverflow.Ellipsis
)
}
// ── 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 ──────────────────────────────────────────────────────────
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)