enhancement: better contact detail screen ux
This commit is contained in:
+165
-34
@@ -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 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user