diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt index d77bdac..4e10657 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/ContactRepository.kt @@ -104,6 +104,35 @@ class ContactRepository(app: Application) { ) } + suspend fun updateAddress( + addressId: String, + type: PaymentAddressType, + address: String, + label: String?, + makeDefault: Boolean + ) { + val existing = dao.getAddressById(addressId) ?: return + val contactId = existing.contactId + + if (makeDefault) dao.clearDefault(contactId, type.name) + + // If LIGHTNING_ADDRESS and default, also sync the fast-lookup column + if (type == PaymentAddressType.LIGHTNING_ADDRESS && makeDefault) { + dao.getById(contactId)?.let { + dao.update(it.copy(lnAddress = address, updatedAt = System.currentTimeMillis())) + } + } + + dao.updateAddress( + addressId = addressId, + type = type.name, + address = address, + label = label, + isDefault = makeDefault + ) + } + + suspend fun removeAddress(addressId: String) { dao.deleteAddressById(addressId) } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt index 353e9ed..5be84a2 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/db/ContactDao.kt @@ -54,8 +54,24 @@ interface ContactDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAddress(address: PaymentAddressEntity) - @Update - suspend fun updateAddress(address: PaymentAddressEntity) + @Query(""" + UPDATE payment_addresses + SET type = :type, + address = :address, + label = :label, + isDefault = :isDefault + WHERE id = :addressId +""") + suspend fun updateAddress( + addressId: String, + type: String, + address: String, + label: String?, + isDefault: Boolean + ) + + @Query("SELECT * FROM payment_addresses WHERE id = :addressId") + suspend fun getAddressById(addressId: String): PaymentAddressEntity? @Query("DELETE FROM payment_addresses WHERE id = :id") suspend fun deleteAddressById(id: String) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt index 35839bc..70f0a4f 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailScreen.kt @@ -4,7 +4,6 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -49,6 +48,7 @@ fun ContactDetailScreen( var isEditing by remember { mutableStateOf(false) } var pendingAddressDelete by remember { mutableStateOf(null) } + var editingAddress by remember { mutableStateOf(null) } Scaffold( topBar = { @@ -131,8 +131,9 @@ fun ContactDetailScreen( onSetDefault = { viewModel.setDefaultAddress(it) }, onPayAddress = onPayAddress, onEditAlias = { viewModel.openEditAliasSheet() }, + onEditAddress = { addr -> editingAddress = addr }, onDeleteContact = { viewModel.requestDelete() }, - modifier = Modifier.padding(innerPadding) + modifier = Modifier.padding(innerPadding).padding(horizontal = 16.dp) ) } } @@ -189,6 +190,18 @@ fun ContactDetailScreen( ) } + // ── Edit address sheet ────────────────────────────────────────── + editingAddress?.let { addr -> + EditAddressSheet( + address = addr, + onSave = { id, type, addressValue, label, makeDefault -> + viewModel.updateAddress(id, type, addressValue, label, makeDefault) + editingAddress = null + }, + onDismiss = { editingAddress = null } + ) + } + // ── Edit alias sheet ────────────────────────────────────────────────── if (showEditAliasSheet) { val currentAlias = (uiState as? ContactDetailUiState.Success) @@ -214,6 +227,7 @@ private fun ContactDetailContent( onSetDefault: (PaymentAddressEntity) -> Unit, onPayAddress: (address: String) -> Unit, onEditAlias: () -> Unit, + onEditAddress: (PaymentAddressEntity) -> Unit, onDeleteContact: () -> Unit, modifier: Modifier = Modifier ) { @@ -282,7 +296,8 @@ private fun ContactDetailContent( isEditing = isEditing, onPay = { onPayAddress(addr.address) }, onRemove = { onRemoveAddress(addr.id) }, - onSetDefault = { onSetDefault(addr) } + onSetDefault = { onSetDefault(addr) }, + onEdit = { onEditAddress(addr) } ) if (index < addresses.lastIndex) { HorizontalDivider( @@ -357,6 +372,7 @@ private fun AddressRow( onPay: () -> Unit, onRemove: () -> Unit, onSetDefault: () -> Unit, + onEdit: () -> Unit ) { val typeLabel = when (address.type) { PaymentAddressType.LIGHTNING_ADDRESS.name -> "Lightning Address" @@ -380,6 +396,13 @@ private fun AddressRow( ) { // ── Label + value ───────────────────────────────────────────── Column(modifier = Modifier.weight(1f)) { + Text( + text = address.address, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) @@ -409,12 +432,6 @@ private fun AddressRow( ) } } - Text( - text = address.address, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) } // ── Trailing actions ────────────────────────────────────────── @@ -428,17 +445,14 @@ private fun AddressRow( // Star + delete only in edit mode if (isEditing) { IconButton( - onClick = onSetDefault, + onClick = onEdit, 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", + imageVector = Icons.Default.Edit, + contentDescription = "Edit address", modifier = Modifier.size(18.dp), - tint = if (address.isDefault) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.primary ) } IconButton( @@ -458,8 +472,6 @@ private fun AddressRow( } - - // ── Add address sheet ────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) @@ -585,6 +597,136 @@ private fun AddAddressSheet( } } +// ── Edit address sheet ──────────────────────────────────────────── + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditAddressSheet( + address: PaymentAddressEntity, + onSave: (id: String, type: PaymentAddressType, addressValue: String, label: String?, makeDefault: Boolean) -> Unit, + onDismiss: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val focusManager = LocalFocusManager.current + val addressFocus = remember { FocusRequester() } + + var selectedType by remember { + mutableStateOf(PaymentAddressType.entries.find { it.name == address.type } + ?: PaymentAddressType.LIGHTNING_ADDRESS) + } + var addressValue by remember { mutableStateOf(address.address) } + var label by remember { mutableStateOf(address.label ?: "") } + var makeDefault by remember { mutableStateOf(address.isDefault) } + + val canSave = addressValue.isNotBlank() + + LaunchedEffect(Unit) { addressFocus.requestFocus() } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text("Edit payment address", style = MaterialTheme.typography.titleMedium) + + // Type chips + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = selectedType == PaymentAddressType.LIGHTNING_ADDRESS, + onClick = { selectedType = PaymentAddressType.LIGHTNING_ADDRESS }, + label = { Text("Lightning Address") } + ) + FilterChip( + selected = selectedType == PaymentAddressType.LNURL, + onClick = { selectedType = PaymentAddressType.LNURL }, + label = { Text("LNURL") } + ) + } + + // Address field + val addressLabel = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS) + "Lightning Address" else "LNURL" + val addressPlaceholder = if (selectedType == PaymentAddressType.LIGHTNING_ADDRESS) + "user@domain.com" else "LNURL1..." + + OutlinedTextField( + value = addressValue, + onValueChange = { addressValue = it }, + label = { Text(addressLabel) }, + placeholder = { Text(addressPlaceholder) }, + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + autoCorrectEnabled = false + ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(addressFocus) + ) + + // Optional label + OutlinedTextField( + value = label, + onValueChange = { label = it }, + label = { Text("Label (optional)") }, + placeholder = { Text("e.g. personal, savings") }, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + if (canSave) onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault) + } + ), + modifier = Modifier.fillMaxWidth() + ) + + // Set as default toggle + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = "Set as default", + style = MaterialTheme.typography.bodyMedium + ) + Switch( + checked = makeDefault, + onCheckedChange = { makeDefault = it } + ) + } + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) + ) { + val strings = LocalAppStrings.current + TextButton(onClick = onDismiss) { Text(strings.cancel) } + Button( + onClick = { + focusManager.clearFocus() + onSave(address.id, selectedType, addressValue, label.takeIf { it.isNotBlank() }, makeDefault) + }, + enabled = canSave + ) { Text("Save") } + } + } + } +} + + // ── Edit alias sheet ─────────────────────────────────────────────────────────── @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt index 7b7fc0b..76f7cca 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactDetailViewModel.kt @@ -68,6 +68,19 @@ class ContactDetailViewModel( } } + fun updateAddress( + addressId: String, + type: PaymentAddressType, + address: String, + label: String?, + makeDefault: Boolean + ) { + viewModelScope.launch { + repo.updateAddress(addressId, type, address, label, makeDefault) + } + } + + fun removeAddress(addressId: String) { viewModelScope.launch { repo.removeAddress(addressId) } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt index a7dc0fa..dd95a18 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/contacts/ContactsViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import com.bitcointxoko.gudariwallet.data.ContactRepository import com.bitcointxoko.gudariwallet.data.db.ContactWithAddresses +import com.bitcointxoko.gudariwallet.data.db.PaymentAddressEntity import com.bitcointxoko.gudariwallet.data.db.PaymentAddressType import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -50,6 +51,7 @@ class ContactsViewModel( } } + class Factory(private val repo: ContactRepository) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T =