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
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -44,6 +45,9 @@ fun ContactDetailScreen(
val showAddAddressSheet by viewModel.showAddAddressSheet.collectAsStateWithLifecycle()
val showEditAliasSheet by viewModel.showEditAliasSheet.collectAsStateWithLifecycle()
var isEditing by remember { mutableStateOf(false) }
var pendingAddressDelete by remember { mutableStateOf<String?>(null) }
Scaffold(
topBar = {
TopAppBar(
@@ -68,22 +72,25 @@ fun ContactDetailScreen(
},
actions = {
if (uiState is ContactDetailUiState.Success) {
IconButton(onClick = { viewModel.openEditAliasSheet() }) {
Icon(Icons.Default.Edit, contentDescription = "Edit name")
if (isEditing) {
// 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 = {
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() }) {
Icon(Icons.Default.Add, contentDescription = "Add payment address")
}
@@ -93,14 +100,18 @@ fun ContactDetailScreen(
when (val state = uiState) {
is ContactDetailUiState.Loading -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ContactDetailUiState.NotFound -> {
Box(
Modifier.fillMaxSize().padding(innerPadding),
Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(
@@ -113,8 +124,11 @@ fun ContactDetailScreen(
is ContactDetailUiState.Success -> {
ContactDetailContent(
state = state,
onRemoveAddress = { viewModel.removeAddress(it) },
isEditing = isEditing,
onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
onSetDefault = { viewModel.setDefaultAddress(it) },
onEditAlias = { viewModel.openEditAliasSheet() },
onDeleteContact = { viewModel.requestDelete() },
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 ─────────────────────────────────────────────────
if (showAddAddressSheet) {
AddAddressSheet(
@@ -171,8 +206,11 @@ fun ContactDetailScreen(
@Composable
private fun ContactDetailContent(
state: ContactDetailUiState.Success,
isEditing: Boolean,
onRemoveAddress: (addressId: String) -> Unit,
onSetDefault: (PaymentAddressEntity) -> Unit,
onEditAlias: () -> Unit,
onDeleteContact: () -> Unit,
modifier: Modifier = Modifier
) {
val contact = state.data.contact
@@ -225,31 +263,33 @@ private fun ContactDetailContent(
// ── Payment addresses section ─────────────────────────────────────
item {
DetailSection(title = "Payment addresses") {}
}
DetailSection(title = "Payment addresses") {
if (addresses.isEmpty()) {
item {
Text(
text = "No addresses added yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
} else {
items(addresses, key = { it.id }) { addr ->
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
addresses.forEachIndexed { index, addr ->
AddressRow(
address = addr,
isEditing = isEditing,
onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) }
)
if (index < addresses.lastIndex) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.outlineVariant,
thickness = 0.5.dp
)
}
}
}
}
}
}
// ── Notes / about (nostr-sourced, read-only for now) ─────────────
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
private fun AddressRow(
address: PaymentAddressEntity,
isEditing: Boolean,
onRemove: () -> Unit,
onSetDefault: () -> Unit
onSetDefault: () -> Unit,
) {
val typeLabel = when (address.type) {
PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address"
@@ -279,45 +361,94 @@ private fun AddressRow(
PaymentAddressType.BIP353.name -> "BIP-353"
else -> address.type
}
val label = address.label?.let { "$typeLabel · $it" } ?: typeLabel
DetailRow(
label = label,
value = address.address,
Row(
modifier = Modifier
.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,
trailingContent = {
Row {
// Set default star — filled if already default
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(
onClick = onSetDefault,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = if (address.isDefault) Icons.Filled.Star
else Icons.Outlined.Star,
contentDescription = if (address.isDefault) "Default address"
else "Set as default",
modifier = Modifier.size(16.dp),
modifier = Modifier.size(18.dp),
tint = if (address.isDefault) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Remove
IconButton(
onClick = onRemove,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove address",
modifier = Modifier.size(16.dp),
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error
)
}
}
}
)
}
}
// ── Add address sheet ──────────────────────────────────────────────────────────