feat: send from contact details

This commit is contained in:
2026-06-13 16:09:58 +02:00
parent 0aa55710a9
commit e57414de17
4 changed files with 47 additions and 4 deletions
@@ -537,19 +537,30 @@ fun WalletScreen(
composable( composable(
route = ROUTE_CONTACT_DETAIL, route = ROUTE_CONTACT_DETAIL,
arguments = listOf(navArgument("contactId") { type = NavType.StringType }), arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) }, enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) } popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry -> ) { backStackEntry ->
val contactId = backStackEntry.arguments?.getString("contactId") ?: "" val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
val detailVm: ContactDetailViewModel = viewModel( val detailVm: ContactDetailViewModel = viewModel(
key = contactId, key = contactId,
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo) factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
) )
ContactDetailScreen( ContactDetailScreen(
viewModel = detailVm, viewModel = detailVm,
onBack = { navController.popBackStack() } onBack = { navController.popBackStack() },
onPayAddress = { address ->
vm.prefillSend(address)
navController.navigate(TabItem.Send.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = false // don't restore stale SendScreen state
}
}
) )
} }
} }
// ── Clipboard banner — floats over content, slides in from top ──── // ── Clipboard banner — floats over content, slides in from top ────
@@ -23,6 +23,7 @@ import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendEvent import com.bitcointxoko.gudariwallet.ui.send.SendEvent
import com.bitcointxoko.gudariwallet.ui.send.SendStrings import com.bitcointxoko.gudariwallet.ui.send.SendStrings
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -116,6 +117,16 @@ class WalletViewModel(
val sendEvents: SharedFlow<SendEvent> = sendVm.events val sendEvents: SharedFlow<SendEvent> = sendVm.events
// ── Contact tap-to-pay prefill ────────────────────────────────────────────
private val _pendingSend = MutableStateFlow<String?>(null)
val pendingSend: StateFlow<String?> get() = _pendingSend
/** Called by ContactDetailScreen before navigating to the Send tab. */
fun prefillSend(address: String) { _pendingSend.value = address }
/** Called by SendScreen once it has consumed the prefilled address. */
fun consumePendingSend() { _pendingSend.value = null }
fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) { fun handleWithdrawScanned(raw: LnurlScanResponse, lnurl: String) {
receiveVm.handleWithdrawResponse(raw, lnurl) receiveVm.handleWithdrawResponse(raw, lnurl)
} }
@@ -1,6 +1,7 @@
package com.bitcointxoko.gudariwallet.ui.contacts package com.bitcointxoko.gudariwallet.ui.contacts
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
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
@@ -37,7 +38,8 @@ import com.bitcointxoko.gudariwallet.ui.common.DetailSection
@Composable @Composable
fun ContactDetailScreen( fun ContactDetailScreen(
viewModel: ContactDetailViewModel, viewModel: ContactDetailViewModel,
onBack: () -> Unit onBack: () -> Unit,
onPayAddress: (address: String) -> Unit = {}
) { ) {
val strings = LocalAppStrings.current val strings = LocalAppStrings.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -127,6 +129,7 @@ fun ContactDetailScreen(
isEditing = isEditing, isEditing = isEditing,
onRemoveAddress = { addressId -> pendingAddressDelete = addressId }, onRemoveAddress = { addressId -> pendingAddressDelete = addressId },
onSetDefault = { viewModel.setDefaultAddress(it) }, onSetDefault = { viewModel.setDefaultAddress(it) },
onPayAddress = onPayAddress,
onEditAlias = { viewModel.openEditAliasSheet() }, onEditAlias = { viewModel.openEditAliasSheet() },
onDeleteContact = { viewModel.requestDelete() }, onDeleteContact = { viewModel.requestDelete() },
modifier = Modifier.padding(innerPadding) modifier = Modifier.padding(innerPadding)
@@ -209,6 +212,7 @@ private fun ContactDetailContent(
isEditing: Boolean, isEditing: Boolean,
onRemoveAddress: (addressId: String) -> Unit, onRemoveAddress: (addressId: String) -> Unit,
onSetDefault: (PaymentAddressEntity) -> Unit, onSetDefault: (PaymentAddressEntity) -> Unit,
onPayAddress: (address: String) -> Unit,
onEditAlias: () -> Unit, onEditAlias: () -> Unit,
onDeleteContact: () -> Unit, onDeleteContact: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@@ -276,6 +280,7 @@ private fun ContactDetailContent(
AddressRow( AddressRow(
address = addr, address = addr,
isEditing = isEditing, isEditing = isEditing,
onPay = { onPayAddress(addr.address) },
onRemove = { onRemoveAddress(addr.id) }, onRemove = { onRemoveAddress(addr.id) },
onSetDefault = { onSetDefault(addr) } onSetDefault = { onSetDefault(addr) }
) )
@@ -349,6 +354,7 @@ private fun ContactDetailContent(
private fun AddressRow( private fun AddressRow(
address: PaymentAddressEntity, address: PaymentAddressEntity,
isEditing: Boolean, isEditing: Boolean,
onPay: () -> Unit,
onRemove: () -> Unit, onRemove: () -> Unit,
onSetDefault: () -> Unit, onSetDefault: () -> Unit,
) { ) {
@@ -365,6 +371,10 @@ private fun AddressRow(
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable(
enabled = !isEditing,
onClickLabel = "Pay with this address"
) { onPay() }
.padding(vertical = 12.dp), .padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -59,6 +59,17 @@ fun SendScreen(
} }
val fiatCurrency = (balanceState as? BalanceState.Success)?.fiatCurrency val fiatCurrency = (balanceState as? BalanceState.Success)?.fiatCurrency
// ── Consume address prefilled from Contacts tap-to-pay ───────────────────
val pendingSend by vm.pendingSend.collectAsStateWithLifecycle()
LaunchedEffect(pendingSend) {
val address = pendingSend
if (!address.isNullOrBlank() && sendState is SendState.Idle) {
input = SendInputDetector.normalize(address)
vm.scan(address, sendStrings)
vm.consumePendingSend()
}
}
// ── Collect NFC offer at top level — never inside a conditional ─────────── // ── Collect NFC offer at top level — never inside a conditional ───────────
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle() val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()