demo: minor UI improvements

This commit is contained in:
Oscar Mira 2024-03-04 13:33:56 +01:00
parent ec4ebe6f78
commit db85c2234f
5 changed files with 142 additions and 96 deletions

View File

@ -1,10 +1,9 @@
package im.molly.monero.demo.ui package im.molly.monero.demo.ui
import androidx.compose.runtime.* import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import im.molly.monero.MoneroNetwork import im.molly.monero.MoneroNetwork
import im.molly.monero.BlockchainTime
import im.molly.monero.RestorePoint import im.molly.monero.RestorePoint
import im.molly.monero.SecretKey import im.molly.monero.SecretKey
import im.molly.monero.demo.AppModule import im.molly.monero.demo.AppModule
@ -17,7 +16,6 @@ import im.molly.monero.util.parseHex
import im.molly.monero.util.toHex import im.molly.monero.util.toHex
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.time.LocalDate import java.time.LocalDate
@ -27,33 +25,24 @@ class AddWalletViewModel(
private val walletRepository: WalletRepository = AppModule.walletRepository, private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() { ) : ViewModel() {
var network by mutableStateOf(DefaultMoneroNetwork) private val viewModelState = MutableStateFlow(AddWalletUiState())
private set
var walletName by mutableStateOf("") val uiState = viewModelState.stateIn(
private set scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
var secretSpendKeyHex by mutableStateOf("") initialValue = viewModelState.value
private set )
var creationDate by mutableStateOf("")
private set
var restoreHeight by mutableStateOf("")
private set
val currentRemoteNodes: StateFlow<List<RemoteNode>> = val currentRemoteNodes: StateFlow<List<RemoteNode>> =
snapshotFlow { network } uiState.flatMapLatest {
.flatMapLatest { remoteNodeRepository.getAllRemoteNodes(
remoteNodeRepository.getAllRemoteNodes( filterNetworkIds = setOf(it.network.id),
filterNetworkIds = setOf(network.id),
)
}
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = emptyList(),
) )
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val selectedRemoteNodes = mutableStateMapOf<Long?, Boolean>() val selectedRemoteNodes = mutableStateMapOf<Long?, Boolean>()
@ -73,21 +62,23 @@ class AddWalletViewModel(
} }
fun toggleSelectedNetwork(network: MoneroNetwork) { fun toggleSelectedNetwork(network: MoneroNetwork) {
this.network = network viewModelState.update { it.copy(network = network) }
} }
fun updateWalletName(name: String) { fun updateWalletName(name: String) {
this.walletName = name viewModelState.update { it.copy(walletName = name) }
} }
fun updateSecretSpendKeyHex(value: String) { fun updateSecretSpendKeyHex(value: String) {
this.secretSpendKeyHex = value viewModelState.update { it.copy(secretSpendKeyHex = value) }
} }
fun recoverFromMnemonic(words: String): Boolean { fun recoverFromMnemonic(words: String): Boolean {
MoneroMnemonic.recoverEntropy(words)?.use { mnemonicCode -> MoneroMnemonic.recoverEntropy(words)?.use { mnemonicCode ->
val secretKey = SecretKey(mnemonicCode.entropy) val secretKey = SecretKey(mnemonicCode.entropy)
secretSpendKeyHex = secretKey.bytes.toHex() viewModelState.update {
it.copy(secretSpendKeyHex = secretKey.bytes.toHex())
}
secretKey.destroy() secretKey.destroy()
return true return true
} }
@ -95,40 +86,76 @@ class AddWalletViewModel(
} }
fun updateCreationDate(value: String) { fun updateCreationDate(value: String) {
this.creationDate = value viewModelState.update { it.copy(creationDate = value) }
} }
fun updateRestoreHeight(value: String) { fun updateRestoreHeight(value: String) {
this.restoreHeight = value viewModelState.update { it.copy(restoreHeight = value) }
} }
fun validateSecretSpendKeyHex(): Boolean = fun validateSecretSpendKeyHex(): Boolean =
secretSpendKeyHex.length == 64 && runCatching { secretSpendKeyHex.parseHex() }.isSuccess with(viewModelState.value) {
return secretSpendKeyHex.length == 64 && runCatching {
secretSpendKeyHex.parseHex()
}.isSuccess
}
fun validateCreationDate(): Boolean = fun validateCreationDate(): Boolean =
creationDate.isEmpty() || runCatching { RestorePoint.creationTime(LocalDate.parse(creationDate)) }.isSuccess with(viewModelState.value) {
creationDate.isEmpty() || runCatching {
RestorePoint.creationTime(LocalDate.parse(creationDate))
}.isSuccess
}
fun validateRestoreHeight(): Boolean = fun validateRestoreHeight(): Boolean =
restoreHeight.isEmpty() || runCatching { RestorePoint.blockHeight(restoreHeight.toInt()) }.isSuccess with(viewModelState.value) {
restoreHeight.isEmpty() || runCatching {
RestorePoint.blockHeight(restoreHeight.toInt())
}.isSuccess
}
fun createWallet() = viewModelScope.launch { fun createWallet() {
walletRepository.addWallet(network, walletName, getSelectedRemoteNodeIds()) val state = viewModelState.getAndUpdate { it.copy(isInProgress = true) }
viewModelScope.launch {
walletRepository.addWallet(state.network, state.walletName, getSelectedRemoteNodeIds())
viewModelState.update { it.copy(walletAdded = true) }
}
} }
fun restoreWallet() = viewModelScope.launch { fun restoreWallet() {
val restorePoint = when { val state = viewModelState.getAndUpdate {
creationDate.isNotEmpty() -> RestorePoint.creationTime(LocalDate.parse(creationDate)) it.copy(isInProgress = true)
restoreHeight.isNotEmpty() -> RestorePoint.blockHeight(restoreHeight.toInt())
else -> RestorePoint.Genesis
} }
SecretKey(secretSpendKeyHex.parseHex()).use { secretSpendKey -> viewModelScope.launch {
walletRepository.restoreWallet( val restorePoint = when {
network, state.creationDate.isNotEmpty() ->
walletName, RestorePoint.creationTime(LocalDate.parse(state.creationDate))
getSelectedRemoteNodeIds(),
secretSpendKey, state.restoreHeight.isNotEmpty() ->
restorePoint RestorePoint.blockHeight(state.restoreHeight.toInt())
)
else -> RestorePoint.Genesis
}
SecretKey(state.secretSpendKeyHex.parseHex()).use { secretSpendKey ->
walletRepository.restoreWallet(
state.network,
state.walletName,
getSelectedRemoteNodeIds(),
secretSpendKey,
restorePoint
)
}
viewModelState.update { it.copy(walletAdded = true) }
} }
} }
} }
data class AddWalletUiState(
val network: MoneroNetwork = DefaultMoneroNetwork,
val walletName: String = "",
val secretSpendKeyHex: String = "",
val creationDate: String = "",
val restoreHeight: String = "",
val isInProgress: Boolean = false,
val walletAdded: Boolean = false,
)

View File

@ -9,6 +9,7 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -88,8 +89,15 @@ fun AddWalletSecondStepRoute(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val remoteNodes by viewModel.currentRemoteNodes.collectAsStateWithLifecycle() val remoteNodes by viewModel.currentRemoteNodes.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState.walletAdded) {
onNavigateToHome()
}
}
SecondStepScreen( SecondStepScreen(
showRestoreOptions = showRestoreOptions, showRestoreOptions = showRestoreOptions,
modifier = modifier, modifier = modifier,
@ -100,15 +108,10 @@ fun AddWalletSecondStepRoute(
} else { } else {
viewModel.createWallet() viewModel.createWallet()
} }
onNavigateToHome()
}, },
walletName = viewModel.walletName, uiState = uiState,
network = viewModel.network,
secretSpendKeyHex = viewModel.secretSpendKeyHex,
secretSpendKeyHexError = !viewModel.validateSecretSpendKeyHex(), secretSpendKeyHexError = !viewModel.validateSecretSpendKeyHex(),
creationDate = viewModel.creationDate,
creationDateError = !viewModel.validateCreationDate(), creationDateError = !viewModel.validateCreationDate(),
restoreHeight = viewModel.restoreHeight,
restoreHeightError = !viewModel.validateRestoreHeight(), restoreHeightError = !viewModel.validateRestoreHeight(),
onWalletNameChanged = { name -> viewModel.updateWalletName(name) }, onWalletNameChanged = { name -> viewModel.updateWalletName(name) },
onNetworkChanged = { network -> viewModel.toggleSelectedNetwork(network) }, onNetworkChanged = { network -> viewModel.toggleSelectedNetwork(network) },
@ -133,14 +136,10 @@ private fun SecondStepScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackClick: () -> Unit = {}, onBackClick: () -> Unit = {},
onCreateClick: () -> Unit = {}, onCreateClick: () -> Unit = {},
walletName: String, uiState: AddWalletUiState,
secretSpendKeyHex: String, secretSpendKeyHexError: Boolean = false,
secretSpendKeyHexError: Boolean, creationDateError: Boolean = false,
creationDate: String, restoreHeightError: Boolean = false,
creationDateError: Boolean,
restoreHeight: String,
restoreHeightError: Boolean,
network: MoneroNetwork,
onWalletNameChanged: (String) -> Unit = {}, onWalletNameChanged: (String) -> Unit = {},
onNetworkChanged: (MoneroNetwork) -> Unit = {}, onNetworkChanged: (MoneroNetwork) -> Unit = {},
onSecretSpendKeyHexChanged: (String) -> Unit = {}, onSecretSpendKeyHexChanged: (String) -> Unit = {},
@ -172,10 +171,11 @@ private fun SecondStepScreen(
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
OutlinedTextField( OutlinedTextField(
value = walletName, value = uiState.walletName,
label = { Text("Wallet name") }, label = { Text("Wallet name") },
onValueChange = onWalletNameChanged, onValueChange = onWalletNameChanged,
singleLine = true, singleLine = true,
enabled = !uiState.isInProgress,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp), .padding(start = 16.dp, end = 16.dp),
@ -183,10 +183,11 @@ private fun SecondStepScreen(
SelectListBox( SelectListBox(
label = "Network", label = "Network",
options = MoneroNetwork.entries.associateWith { it.name }, options = MoneroNetwork.entries.associateWith { it.name },
selectedOption = network, selectedOption = uiState.network,
onOptionClick = { onOptionClick = {
onNetworkChanged(it) onNetworkChanged(it)
}, },
enabled = !uiState.isInProgress,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
@ -194,31 +195,36 @@ private fun SecondStepScreen(
Text( Text(
text = "Remote node selection", text = "Remote node selection",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp), modifier = (if (uiState.isInProgress) Modifier.alpha(0.3f) else Modifier)
.padding(16.dp),
) )
MultiSelectRemoteNodeList( MultiSelectRemoteNodeList(
remoteNodes = remoteNodes, remoteNodes = remoteNodes,
selectedIds = selectedRemoteNodeIds, selectedIds = selectedRemoteNodeIds,
enabled = !uiState.isInProgress,
modifier = Modifier.padding(start = 16.dp), modifier = Modifier.padding(start = 16.dp),
) )
if (showRestoreOptions) { if (showRestoreOptions) {
Text( Text(
text = "Deterministic wallet recovery", text = "Deterministic wallet recovery",
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp), modifier = (if (uiState.isInProgress) Modifier.alpha(0.3f) else Modifier)
.padding(16.dp),
) )
OutlinedTextField( OutlinedTextField(
value = secretSpendKeyHex, value = uiState.secretSpendKeyHex,
label = { Text("Secret spend key") }, label = { Text("Secret spend key") },
onValueChange = onSecretSpendKeyHexChanged, onValueChange = onSecretSpendKeyHexChanged,
singleLine = true, singleLine = true,
isError = secretSpendKeyHexError, isError = secretSpendKeyHexError,
enabled = !uiState.isInProgress,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp), .padding(start = 16.dp, end = 16.dp),
) )
TextButton( TextButton(
onClick = { showMnemonicDialog = true }, onClick = { showMnemonicDialog = true },
enabled = !uiState.isInProgress,
modifier = Modifier modifier = Modifier
.padding(start = 16.dp), .padding(start = 16.dp),
) { ) {
@ -230,23 +236,23 @@ private fun SecondStepScreen(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) )
OutlinedTextField( OutlinedTextField(
value = creationDate, value = uiState.creationDate,
label = { Text("Wallet creation date") }, label = { Text("Wallet creation date") },
onValueChange = onCreationDateChanged, onValueChange = onCreationDateChanged,
singleLine = true, singleLine = true,
isError = creationDateError, isError = creationDateError,
enabled = restoreHeight.isEmpty(), enabled = uiState.restoreHeight.isEmpty() && !uiState.isInProgress,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp, end = 16.dp), .padding(start = 16.dp, end = 16.dp),
) )
OutlinedTextField( OutlinedTextField(
value = restoreHeight, value = uiState.restoreHeight,
label = { Text("Restore height") }, label = { Text("Restore height") },
onValueChange = onRestoreHeightChanged, onValueChange = onRestoreHeightChanged,
singleLine = true, singleLine = true,
isError = restoreHeightError, isError = restoreHeightError,
enabled = creationDate.isEmpty(), enabled = uiState.creationDate.isEmpty() && !uiState.isInProgress,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -268,10 +274,10 @@ private fun SecondStepScreen(
onCreateClick() onCreateClick()
} }
}, },
enabled = validInput, enabled = validInput && !uiState.isInProgress,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) { ) {
Text("Finish") Text(if (uiState.isInProgress) "Adding wallet..." else "Finish")
} }
} }
} }
@ -336,14 +342,11 @@ private fun CreateWalletScreenPreview() {
AppTheme { AppTheme {
SecondStepScreen( SecondStepScreen(
showRestoreOptions = false, showRestoreOptions = false,
walletName = "Personal", uiState = AddWalletUiState(
network = DefaultMoneroNetwork, walletName = "Personal",
secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706", network = DefaultMoneroNetwork,
secretSpendKeyHexError = false, secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706",
creationDate = "", ),
creationDateError = false,
restoreHeight = "",
restoreHeightError = false,
remoteNodes = listOf(RemoteNode.EMPTY), remoteNodes = listOf(RemoteNode.EMPTY),
selectedRemoteNodeIds = mutableMapOf(), selectedRemoteNodeIds = mutableMapOf(),
) )
@ -356,14 +359,11 @@ private fun RestoreWalletScreenPreview() {
AppTheme { AppTheme {
SecondStepScreen( SecondStepScreen(
showRestoreOptions = true, showRestoreOptions = true,
walletName = "Personal", uiState = AddWalletUiState(
network = DefaultMoneroNetwork, walletName = "Personal",
secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706", network = DefaultMoneroNetwork,
secretSpendKeyHexError = false, secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706",
creationDate = "", ),
creationDateError = false,
restoreHeight = "",
restoreHeightError = false,
remoteNodes = listOf(RemoteNode.EMPTY), remoteNodes = listOf(RemoteNode.EMPTY),
selectedRemoteNodeIds = mutableMapOf(), selectedRemoteNodeIds = mutableMapOf(),
) )

View File

@ -45,7 +45,7 @@ fun AddressCardExpanded(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
Text( Text(
text = "Total Balance: ${totalAmount.toFormattedString(appendSymbol = true)}", text = "Total balance: ${totalAmount.toFormattedString(appendSymbol = true)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
Text( Text(

View File

@ -49,7 +49,11 @@ fun EditableRecipientList(
onRecipientChange(updatedList) onRecipientChange(updatedList)
}, },
onDeleteItemClick = { onDeleteItemClick = {
updatedList.removeAt(index) if (updatedList.size > 1) {
updatedList.removeAt(index)
} else {
updatedList[0] = "" to ""
}
onRecipientChange(updatedList) onRecipientChange(updatedList)
} }
) )
@ -104,7 +108,7 @@ fun PaymentDetailItem(
) )
IconButton( IconButton(
onClick = onDeleteItemClick, onClick = onDeleteItemClick,
enabled = enabled && itemIndex > 0, enabled = enabled,
modifier = Modifier.padding(top = 12.dp), modifier = Modifier.padding(top = 12.dp),
) { ) {
Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete") Icon(imageVector = Icons.Default.Delete, contentDescription = "Delete")

View File

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import im.molly.monero.demo.data.model.RemoteNode import im.molly.monero.demo.data.model.RemoteNode
import im.molly.monero.demo.ui.theme.AppIcons import im.molly.monero.demo.ui.theme.AppIcons
@ -14,6 +15,7 @@ fun MultiSelectRemoteNodeList(
remoteNodes: List<RemoteNode>, remoteNodes: List<RemoteNode>,
selectedIds: MutableMap<Long?, Boolean>, selectedIds: MutableMap<Long?, Boolean>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
enabled: Boolean = true,
) { ) {
Column( Column(
modifier = modifier, modifier = modifier,
@ -27,6 +29,7 @@ fun MultiSelectRemoteNodeList(
onCheckedChange = { checked -> onCheckedChange = { checked ->
selectedIds[remoteNode.id] = checked selectedIds[remoteNode.id] = checked
}, },
enabled = enabled,
) )
} }
} else { } else {
@ -63,17 +66,29 @@ private fun RemoteNodeItem(
checked: Boolean = false, checked: Boolean = false,
showCheckbox: Boolean = false, showCheckbox: Boolean = false,
showMenu: Boolean = false, showMenu: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit = {}, onCheckedChange: (Boolean) -> Unit = {},
onEditClick: (RemoteNode) -> Unit = {}, onEditClick: (RemoteNode) -> Unit = {},
onDeleteClick: (RemoteNode) -> Unit = {}, onDeleteClick: (RemoteNode) -> Unit = {},
) { ) {
ListItem( ListItem(
headlineContent = { Text(remoteNode.uri.toString()) }, headlineContent = {
overlineContent = { Text(remoteNode.network.name.uppercase()) }, Text(
text = remoteNode.uri.toString(),
modifier = (if (enabled) Modifier else Modifier.alpha(0.3f)),
)
},
overlineContent = {
Text(
text = remoteNode.network.name.uppercase(),
modifier = (if (enabled) Modifier else Modifier.alpha(0.3f)),
)
},
trailingContent = { trailingContent = {
if (showCheckbox) { if (showCheckbox) {
Checkbox( Checkbox(
checked = checked, checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
) )
} }