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
import androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import im.molly.monero.MoneroNetwork
import im.molly.monero.BlockchainTime
import im.molly.monero.RestorePoint
import im.molly.monero.SecretKey
import im.molly.monero.demo.AppModule
@ -17,7 +16,6 @@ import im.molly.monero.util.parseHex
import im.molly.monero.util.toHex
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.launch
import java.time.LocalDate
@ -27,33 +25,24 @@ class AddWalletViewModel(
private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
var network by mutableStateOf(DefaultMoneroNetwork)
private set
private val viewModelState = MutableStateFlow(AddWalletUiState())
var walletName by mutableStateOf("")
private set
var secretSpendKeyHex by mutableStateOf("")
private set
var creationDate by mutableStateOf("")
private set
var restoreHeight by mutableStateOf("")
private set
val uiState = viewModelState.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = viewModelState.value
)
val currentRemoteNodes: StateFlow<List<RemoteNode>> =
snapshotFlow { network }
.flatMapLatest {
remoteNodeRepository.getAllRemoteNodes(
filterNetworkIds = setOf(network.id),
)
}
.stateIn(
scope = viewModelScope,
started = WhileSubscribed(5000),
initialValue = emptyList(),
uiState.flatMapLatest {
remoteNodeRepository.getAllRemoteNodes(
filterNetworkIds = setOf(it.network.id),
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
val selectedRemoteNodes = mutableStateMapOf<Long?, Boolean>()
@ -73,21 +62,23 @@ class AddWalletViewModel(
}
fun toggleSelectedNetwork(network: MoneroNetwork) {
this.network = network
viewModelState.update { it.copy(network = network) }
}
fun updateWalletName(name: String) {
this.walletName = name
viewModelState.update { it.copy(walletName = name) }
}
fun updateSecretSpendKeyHex(value: String) {
this.secretSpendKeyHex = value
viewModelState.update { it.copy(secretSpendKeyHex = value) }
}
fun recoverFromMnemonic(words: String): Boolean {
MoneroMnemonic.recoverEntropy(words)?.use { mnemonicCode ->
val secretKey = SecretKey(mnemonicCode.entropy)
secretSpendKeyHex = secretKey.bytes.toHex()
viewModelState.update {
it.copy(secretSpendKeyHex = secretKey.bytes.toHex())
}
secretKey.destroy()
return true
}
@ -95,40 +86,76 @@ class AddWalletViewModel(
}
fun updateCreationDate(value: String) {
this.creationDate = value
viewModelState.update { it.copy(creationDate = value) }
}
fun updateRestoreHeight(value: String) {
this.restoreHeight = value
viewModelState.update { it.copy(restoreHeight = value) }
}
fun validateSecretSpendKeyHex(): Boolean =
secretSpendKeyHex.length == 64 && runCatching { secretSpendKeyHex.parseHex() }.isSuccess
with(viewModelState.value) {
return secretSpendKeyHex.length == 64 && runCatching {
secretSpendKeyHex.parseHex()
}.isSuccess
}
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 =
restoreHeight.isEmpty() || runCatching { RestorePoint.blockHeight(restoreHeight.toInt()) }.isSuccess
with(viewModelState.value) {
restoreHeight.isEmpty() || runCatching {
RestorePoint.blockHeight(restoreHeight.toInt())
}.isSuccess
}
fun createWallet() = viewModelScope.launch {
walletRepository.addWallet(network, walletName, getSelectedRemoteNodeIds())
fun createWallet() {
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 {
val restorePoint = when {
creationDate.isNotEmpty() -> RestorePoint.creationTime(LocalDate.parse(creationDate))
restoreHeight.isNotEmpty() -> RestorePoint.blockHeight(restoreHeight.toInt())
else -> RestorePoint.Genesis
fun restoreWallet() {
val state = viewModelState.getAndUpdate {
it.copy(isInProgress = true)
}
SecretKey(secretSpendKeyHex.parseHex()).use { secretSpendKey ->
walletRepository.restoreWallet(
network,
walletName,
getSelectedRemoteNodeIds(),
secretSpendKey,
restorePoint
)
viewModelScope.launch {
val restorePoint = when {
state.creationDate.isNotEmpty() ->
RestorePoint.creationTime(LocalDate.parse(state.creationDate))
state.restoreHeight.isNotEmpty() ->
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
@ -88,8 +89,15 @@ fun AddWalletSecondStepRoute(
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val remoteNodes by viewModel.currentRemoteNodes.collectAsStateWithLifecycle()
LaunchedEffect(uiState) {
if (uiState.walletAdded) {
onNavigateToHome()
}
}
SecondStepScreen(
showRestoreOptions = showRestoreOptions,
modifier = modifier,
@ -100,15 +108,10 @@ fun AddWalletSecondStepRoute(
} else {
viewModel.createWallet()
}
onNavigateToHome()
},
walletName = viewModel.walletName,
network = viewModel.network,
secretSpendKeyHex = viewModel.secretSpendKeyHex,
uiState = uiState,
secretSpendKeyHexError = !viewModel.validateSecretSpendKeyHex(),
creationDate = viewModel.creationDate,
creationDateError = !viewModel.validateCreationDate(),
restoreHeight = viewModel.restoreHeight,
restoreHeightError = !viewModel.validateRestoreHeight(),
onWalletNameChanged = { name -> viewModel.updateWalletName(name) },
onNetworkChanged = { network -> viewModel.toggleSelectedNetwork(network) },
@ -133,14 +136,10 @@ private fun SecondStepScreen(
modifier: Modifier = Modifier,
onBackClick: () -> Unit = {},
onCreateClick: () -> Unit = {},
walletName: String,
secretSpendKeyHex: String,
secretSpendKeyHexError: Boolean,
creationDate: String,
creationDateError: Boolean,
restoreHeight: String,
restoreHeightError: Boolean,
network: MoneroNetwork,
uiState: AddWalletUiState,
secretSpendKeyHexError: Boolean = false,
creationDateError: Boolean = false,
restoreHeightError: Boolean = false,
onWalletNameChanged: (String) -> Unit = {},
onNetworkChanged: (MoneroNetwork) -> Unit = {},
onSecretSpendKeyHexChanged: (String) -> Unit = {},
@ -172,10 +171,11 @@ private fun SecondStepScreen(
.verticalScroll(rememberScrollState()),
) {
OutlinedTextField(
value = walletName,
value = uiState.walletName,
label = { Text("Wallet name") },
onValueChange = onWalletNameChanged,
singleLine = true,
enabled = !uiState.isInProgress,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
@ -183,10 +183,11 @@ private fun SecondStepScreen(
SelectListBox(
label = "Network",
options = MoneroNetwork.entries.associateWith { it.name },
selectedOption = network,
selectedOption = uiState.network,
onOptionClick = {
onNetworkChanged(it)
},
enabled = !uiState.isInProgress,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
@ -194,31 +195,36 @@ private fun SecondStepScreen(
Text(
text = "Remote node selection",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
modifier = (if (uiState.isInProgress) Modifier.alpha(0.3f) else Modifier)
.padding(16.dp),
)
MultiSelectRemoteNodeList(
remoteNodes = remoteNodes,
selectedIds = selectedRemoteNodeIds,
enabled = !uiState.isInProgress,
modifier = Modifier.padding(start = 16.dp),
)
if (showRestoreOptions) {
Text(
text = "Deterministic wallet recovery",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp),
modifier = (if (uiState.isInProgress) Modifier.alpha(0.3f) else Modifier)
.padding(16.dp),
)
OutlinedTextField(
value = secretSpendKeyHex,
value = uiState.secretSpendKeyHex,
label = { Text("Secret spend key") },
onValueChange = onSecretSpendKeyHexChanged,
singleLine = true,
isError = secretSpendKeyHexError,
enabled = !uiState.isInProgress,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
)
TextButton(
onClick = { showMnemonicDialog = true },
enabled = !uiState.isInProgress,
modifier = Modifier
.padding(start = 16.dp),
) {
@ -230,23 +236,23 @@ private fun SecondStepScreen(
modifier = Modifier.padding(16.dp),
)
OutlinedTextField(
value = creationDate,
value = uiState.creationDate,
label = { Text("Wallet creation date") },
onValueChange = onCreationDateChanged,
singleLine = true,
isError = creationDateError,
enabled = restoreHeight.isEmpty(),
enabled = uiState.restoreHeight.isEmpty() && !uiState.isInProgress,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp),
)
OutlinedTextField(
value = restoreHeight,
value = uiState.restoreHeight,
label = { Text("Restore height") },
onValueChange = onRestoreHeightChanged,
singleLine = true,
isError = restoreHeightError,
enabled = creationDate.isEmpty(),
enabled = uiState.creationDate.isEmpty() && !uiState.isInProgress,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier
.fillMaxWidth()
@ -268,10 +274,10 @@ private fun SecondStepScreen(
onCreateClick()
}
},
enabled = validInput,
enabled = validInput && !uiState.isInProgress,
modifier = Modifier.padding(16.dp),
) {
Text("Finish")
Text(if (uiState.isInProgress) "Adding wallet..." else "Finish")
}
}
}
@ -336,14 +342,11 @@ private fun CreateWalletScreenPreview() {
AppTheme {
SecondStepScreen(
showRestoreOptions = false,
walletName = "Personal",
network = DefaultMoneroNetwork,
secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706",
secretSpendKeyHexError = false,
creationDate = "",
creationDateError = false,
restoreHeight = "",
restoreHeightError = false,
uiState = AddWalletUiState(
walletName = "Personal",
network = DefaultMoneroNetwork,
secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706",
),
remoteNodes = listOf(RemoteNode.EMPTY),
selectedRemoteNodeIds = mutableMapOf(),
)
@ -356,14 +359,11 @@ private fun RestoreWalletScreenPreview() {
AppTheme {
SecondStepScreen(
showRestoreOptions = true,
walletName = "Personal",
network = DefaultMoneroNetwork,
secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706",
secretSpendKeyHexError = false,
creationDate = "",
creationDateError = false,
restoreHeight = "",
restoreHeightError = false,
uiState = AddWalletUiState(
walletName = "Personal",
network = DefaultMoneroNetwork,
secretSpendKeyHex = "d2ca26e22489bd9871c910c58dee3ab08e66b9d566825a064c8c0af061cd8706",
),
remoteNodes = listOf(RemoteNode.EMPTY),
selectedRemoteNodeIds = mutableMapOf(),
)

View File

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

View File

@ -49,7 +49,11 @@ fun EditableRecipientList(
onRecipientChange(updatedList)
},
onDeleteItemClick = {
updatedList.removeAt(index)
if (updatedList.size > 1) {
updatedList.removeAt(index)
} else {
updatedList[0] = "" to ""
}
onRecipientChange(updatedList)
}
)
@ -104,7 +108,7 @@ fun PaymentDetailItem(
)
IconButton(
onClick = onDeleteItemClick,
enabled = enabled && itemIndex > 0,
enabled = enabled,
modifier = Modifier.padding(top = 12.dp),
) {
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.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.tooling.preview.Preview
import im.molly.monero.demo.data.model.RemoteNode
import im.molly.monero.demo.ui.theme.AppIcons
@ -14,6 +15,7 @@ fun MultiSelectRemoteNodeList(
remoteNodes: List<RemoteNode>,
selectedIds: MutableMap<Long?, Boolean>,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
Column(
modifier = modifier,
@ -27,6 +29,7 @@ fun MultiSelectRemoteNodeList(
onCheckedChange = { checked ->
selectedIds[remoteNode.id] = checked
},
enabled = enabled,
)
}
} else {
@ -63,17 +66,29 @@ private fun RemoteNodeItem(
checked: Boolean = false,
showCheckbox: Boolean = false,
showMenu: Boolean = false,
enabled: Boolean = true,
onCheckedChange: (Boolean) -> Unit = {},
onEditClick: (RemoteNode) -> Unit = {},
onDeleteClick: (RemoteNode) -> Unit = {},
) {
ListItem(
headlineContent = { Text(remoteNode.uri.toString()) },
overlineContent = { Text(remoteNode.network.name.uppercase()) },
headlineContent = {
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 = {
if (showCheckbox) {
Checkbox(
checked = checked,
enabled = enabled,
onCheckedChange = onCheckedChange,
)
}