demo: transaction screen and components

This commit is contained in:
Oscar Mira 2023-10-22 12:42:05 +02:00
parent a84375d384
commit 42cf100951
27 changed files with 794 additions and 224 deletions

View File

@ -58,6 +58,9 @@ class WalletRepository(
emitAll(getWallet(walletId).ledger())
}
fun getTransaction(walletId: Long, txId: String): Flow<Transaction?> =
getLedger(walletId).map { it.transactions[txId] }
suspend fun addWallet(
moneroNetwork: MoneroNetwork,
name: String,

View File

@ -1,9 +0,0 @@
package im.molly.monero.demo.data.model
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Transaction(
@PrimaryKey val uid: Int,
)

View File

@ -0,0 +1,8 @@
package im.molly.monero.demo.data.model
import im.molly.monero.Transaction
data class WalletTransaction(
val walletId: Long,
val transaction: Transaction,
)

View File

@ -1,20 +1,37 @@
package im.molly.monero.demo.ui
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import im.molly.monero.demo.ui.component.Toolbar
@Composable
fun HistoryRoute(
modifier: Modifier = Modifier,
navigateToTransaction: (String, Long) -> Unit,
) {
HistoryScreen(
modifier = modifier,
onTransactionClick = navigateToTransaction,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun HistoryScreen(
modifier: Modifier = Modifier,
onTransactionClick: (String, Long) -> Unit,
) {
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Toolbar(
title = "Transaction history",
scrollBehavior = scrollBehavior,
)
},
) { padding ->
// TODO
}
}

View File

@ -18,9 +18,9 @@ import im.molly.monero.demo.ui.component.Toolbar
fun HomeRoute(
navigateToAddWalletWizard: () -> Unit,
navigateToWallet: (Long) -> Unit,
viewModel: HomeViewModel = viewModel(),
walletListViewModel: WalletListViewModel = viewModel(),
) {
val walletListUiState: WalletListUiState by viewModel.walletListUiState.collectAsStateWithLifecycle()
val walletListUiState by walletListViewModel.uiState.collectAsStateWithLifecycle()
HomeScreen(
walletListUiState = walletListUiState,
@ -56,8 +56,8 @@ private fun HomeScreen(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(padding))
{
.padding(padding),
) {
walletCards(walletListUiState, onWalletClick)
}
}
@ -71,8 +71,11 @@ private fun LazyListScope.walletCards(
WalletListUiState.Loading -> item {
Text(text = "Loading wallet list...") // TODO
}
is WalletListUiState.Success -> {
walletCardsItems(walletListUiState.ids, onWalletClick)
is WalletListUiState.Loaded -> {
walletCardItems(walletListUiState.walletIds, onWalletClick)
}
is WalletListUiState.Empty -> Unit // TODO
}
}

View File

@ -1,36 +0,0 @@
package im.molly.monero.demo.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.*
class HomeViewModel(
private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
val walletListUiState: StateFlow<WalletListUiState> = walletRepository.getWalletIdList()
.map { WalletListUiState.Success(it) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = WalletListUiState.Loading,
)
}
sealed interface HomeUiState {
data object Loading : HomeUiState
// data class Ready(
// val wallets: List<WalletDetails>
// ) : HomeUiState
data object Empty : HomeUiState
}
sealed interface WalletListUiState {
data class Success(val ids: List<Long>) : WalletListUiState
data object Loading : WalletListUiState
}

View File

@ -0,0 +1,87 @@
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import im.molly.monero.MoneroCurrency
import im.molly.monero.Transaction
import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionCardExpanded(
transaction: Transaction,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
onClick = onClick,
modifier = modifier.padding(top = 8.dp, start = 8.dp, end = 8.dp)
) {
Column(
modifier = Modifier.padding(12.dp),
) {
TransactionDetail("State", transaction.state.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Sent", transaction.sent.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Received", transaction.received.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Payments", transaction.payments.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Time lock", transaction.timeLock.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Change", MoneroCurrency.format(transaction.change))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Fee", MoneroCurrency.format(transaction.fee))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Transaction ID", transaction.txId)
Spacer(modifier = Modifier.height(12.dp))
}
}
}
@Composable
private fun TransactionDetail(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Column {
Text(label, style = MaterialTheme.typography.labelMedium, modifier = modifier)
Text(value, style = MaterialTheme.typography.bodyMedium, modifier = modifier)
}
}
@Preview
@Composable
private fun TransactionCardExpandedPreview(
@PreviewParameter(
TransactionCardPreviewParameterProvider::class,
limit = 1,
) transactions: List<Transaction>,
) {
AppTheme {
Surface {
TransactionCardExpanded(
transaction = transactions.first(),
onClick = {},
)
}
}
}
private class TransactionCardPreviewParameterProvider : PreviewParameterProvider<List<Transaction>> {
override val values = sequenceOf(PreviewParameterData.transactions)
}

View File

@ -0,0 +1,23 @@
package im.molly.monero.demo.ui
import TransactionCardExpanded
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
import im.molly.monero.demo.data.model.WalletTransaction
fun LazyListScope.transactionCardItems(
items: List<WalletTransaction>,
onTransactionClick: (txId: String, walletId: Long) -> Unit,
itemModifier: Modifier = Modifier,
) = items(
items = items,
key = { it.transaction.txId },
itemContent = {
TransactionCardExpanded(
transaction = it.transaction,
onClick = { onTransactionClick(it.transaction.txId, it.walletId) },
modifier = itemModifier,
)
},
)

View File

@ -0,0 +1,158 @@
package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.MoneroCurrency
import im.molly.monero.Transaction
import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme
@Composable
fun TransactionRoute(
txId: String,
walletId: Long,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TransactionViewModel = viewModel(
factory = TransactionViewModel.factory(txId, walletId),
key = TransactionViewModel.key(txId, walletId),
)
) {
val uiState: TxUiState by viewModel.uiState.collectAsStateWithLifecycle()
TransactionScreen(
uiState = uiState,
onBackClick = onBackClick,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TransactionScreen(
uiState: TxUiState,
modifier: Modifier = Modifier,
onBackClick: () -> Unit,
) {
Scaffold(topBar = {
Toolbar(
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
},
title = "Transaction details",
)
}) { padding ->
Box(
modifier = modifier
.padding(padding)
.verticalScroll(rememberScrollState()),
) {
Column(Modifier.padding(10.dp)) {
when (uiState) {
is TxUiState.Loaded -> {
val tx = uiState.transaction
TransactionDetail("State", tx.state.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Sent", tx.sent.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Received", tx.received.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Payments", tx.payments.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Time lock", tx.timeLock.toString())
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Change", MoneroCurrency.format(tx.change))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Fee", MoneroCurrency.format(tx.fee))
Spacer(modifier = Modifier.height(12.dp))
TransactionDetail("Transaction ID", tx.txId)
Spacer(modifier = Modifier.height(12.dp))
}
else -> Unit
}
}
}
}
}
@Composable
private fun TransactionDetail(
label: String,
value: String,
modifier: Modifier = Modifier,
) {
Column() {
Text(label, style = MaterialTheme.typography.labelMedium, modifier = modifier)
Text(value, style = MaterialTheme.typography.bodyMedium, modifier = modifier)
}
}
@Preview
@Composable
private fun TransactionScreenPopulated(
@PreviewParameter(
TransactionScreenPreviewParameterProvider::class,
limit = 1,
) transactions: List<Transaction>,
) {
AppTheme {
TransactionScreen(
uiState = TxUiState.Loaded(transactions.first()),
onBackClick = {},
)
}
}
@Preview
@Composable
private fun TransactionScreenError() {
AppTheme {
TransactionScreen(
uiState = TxUiState.Error,
onBackClick = {},
)
}
}
@Preview
@Composable
private fun TransactionScreenNotFound() {
AppTheme {
TransactionScreen(
uiState = TxUiState.NotFound,
onBackClick = {},
)
}
}
class TransactionScreenPreviewParameterProvider : PreviewParameterProvider<List<Transaction>> {
override val values = sequenceOf(PreviewParameterData.transactions)
}

View File

@ -0,0 +1,58 @@
package im.molly.monero.demo.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import im.molly.monero.Transaction
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
class TransactionViewModel(
txId: String,
walletId: Long,
walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
val uiState: StateFlow<TxUiState> =
walletRepository.getTransaction(walletId, txId)
.asResult()
.map { result ->
when (result) {
is Result.Success -> result.data?.let { tx ->
TxUiState.Loaded(tx)
} ?: TxUiState.NotFound
is Result.Error -> TxUiState.Error
is Result.Loading -> TxUiState.Loading
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = TxUiState.Loading
)
companion object {
fun factory(txId: String, walletId: Long) = viewModelFactory {
initializer {
TransactionViewModel(txId, walletId)
}
}
fun key(txId: String, walletId: Long): String = "tx_$txId:$walletId"
}
}
sealed interface TxUiState {
data class Loaded(val transaction: Transaction) : TxUiState
data object Error : TxUiState
data object Loading : TxUiState
data object NotFound : TxUiState
}

View File

@ -3,7 +3,9 @@ package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Divider
import androidx.compose.material3.MaterialTheme
@ -51,10 +53,12 @@ fun WalletBalanceView(
.padding(16.dp)
) {
Text(
style = MaterialTheme.typography.headlineSmall,
style = MaterialTheme.typography.bodyLarge,
text = "Balance at Block #${blockchainTime.height}",
)
Spacer(modifier = Modifier.height(12.dp))
BalanceRow("Confirmed", balance.confirmedAmount)
BalanceRow("Pending", balance.pendingAmount)
Divider()
@ -71,7 +75,6 @@ fun WalletBalanceView(
fun BalanceRow(
label: String,
amount: MoneroAmount,
format: MoneroCurrency.Format = MoneroCurrency.Format(),
) {
Row(
modifier = Modifier
@ -79,8 +82,8 @@ fun BalanceRow(
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label)
Text(text = format.format(amount))
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Text(text = MoneroCurrency.format(amount), style = MaterialTheme.typography.bodyMedium)
}
}
@ -90,7 +93,6 @@ fun LockedBalanceRow(
amount: MoneroAmount,
blockCount: Int,
timeRemaining: Duration,
moneroFormat: MoneroCurrency.Format = MoneroCurrency.DefaultFormat,
) {
Row(
modifier = Modifier
@ -101,10 +103,10 @@ fun LockedBalanceRow(
val durationText =
"${timeRemaining.toKotlinDuration()} ($blockCount blocks)"
Column {
Text(text = label)
Text(text = durationText, fontSize = 12.sp)
Text(text = label, style = MaterialTheme.typography.bodyMedium)
Text(text = durationText, style = MaterialTheme.typography.bodySmall)
}
Text(text = moneroFormat.format(amount))
Text(text = MoneroCurrency.format(amount), style = MaterialTheme.typography.bodyMedium)
}
}

View File

@ -5,50 +5,81 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.Balance
import im.molly.monero.Ledger
import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.ui.theme.AppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletCard(
walletId: Long,
onClick: () -> Unit,
modifier: Modifier = Modifier,
onClick: (Long) -> Unit,
viewModel: WalletViewModel = viewModel(
factory = WalletViewModel.factory(walletId),
key = walletId.toString(),
key = WalletViewModel.key(walletId),
),
) {
val walletUiState: WalletUiState by viewModel.walletUiState.collectAsStateWithLifecycle()
val uiState: WalletUiState by viewModel.uiState.collectAsStateWithLifecycle()
Card(
onClick = { onClick(walletId) },
modifier = modifier
.padding(8.dp)
onClick = onClick, modifier = modifier.padding(8.dp)
) {
Column {
when (walletUiState) {
WalletUiState.Error -> {
Text(text = "Error") // TODO
}
WalletUiState.Loading -> {
Text(text = "Loading wallet...") // TODO
}
is WalletUiState.Success -> {
val state = walletUiState as WalletUiState.Success
Row {
Text(text = "Name: ${state.config.name}")
}
Row {
Text(text = "Ledger: ${state.ledger}")
}
when (uiState) {
is WalletUiState.Loaded -> {
WalletCardExpanded(
(uiState as WalletUiState.Loaded).config,
(uiState as WalletUiState.Loaded).balance,
)
}
WalletUiState.Error -> WalletCardError()
WalletUiState.Loading -> WalletCardLoading()
}
}
}
}
@Composable
fun WalletCardExpanded(
config: WalletConfig,
balance: Balance,
) {
Row {
Text(text = "Name: ${config.name}")
}
Row {
Text(text = "Ledger: $balance")
}
}
@Composable
fun WalletCardError() {
Text(text = "Error") // TODO
}
@Composable
fun WalletCardLoading() {
Text(text = "Loading wallet...") // TODO
}
//@Preview
//@Composable
//private fun WalletCardExpandedPreview() {
// AppTheme {
// Surface {
// WalletCardExpanded()
// }
// }
//}

View File

@ -4,9 +4,9 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.ui.Modifier
fun LazyListScope.walletCardsItems(
fun LazyListScope.walletCardItems(
items: List<Long>,
onItemClick: (Long) -> Unit,
onItemClick: (walletId: Long) -> Unit,
itemModifier: Modifier = Modifier,
) = items(
items = items,
@ -14,7 +14,7 @@ fun LazyListScope.walletCardsItems(
itemContent = {
WalletCard(
walletId = it,
onClick = onItemClick,
onClick = { onItemClick(it) },
modifier = itemModifier,
)
},

View File

@ -0,0 +1,31 @@
package im.molly.monero.demo.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.data.WalletRepository
import kotlinx.coroutines.flow.*
class WalletListViewModel(
walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
val uiState: StateFlow<WalletListUiState> =
walletRepository.getWalletIdList().map { walletIds ->
if (walletIds.isNotEmpty()) {
WalletListUiState.Loaded(walletIds)
} else {
WalletListUiState.Empty
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = WalletListUiState.Loading,
)
}
sealed interface WalletListUiState {
data class Loaded(val walletIds: List<Long>) : WalletListUiState
data object Loading : WalletListUiState
data object Empty : WalletListUiState
}

View File

@ -1,39 +1,46 @@
package im.molly.monero.demo.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import im.molly.monero.BlockchainTime
import im.molly.monero.Ledger
import im.molly.monero.MoneroCurrency
import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.ui.component.Toolbar
import im.molly.monero.demo.ui.preview.PreviewParameterData
import im.molly.monero.demo.ui.theme.AppIcons
import im.molly.monero.demo.ui.theme.AppTheme
@Composable
fun WalletRoute(
walletId: Long,
onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: WalletViewModel = viewModel(
factory = WalletViewModel.factory(walletId),
key = walletId.toString(),
key = WalletViewModel.key(walletId),
)
) {
val walletUiState: WalletUiState by viewModel.walletUiState.collectAsStateWithLifecycle()
val uiState: WalletUiState by viewModel.uiState.collectAsStateWithLifecycle()
WalletScreen(
uiState = walletUiState,
uiState = uiState,
onWalletConfigChange = { config -> viewModel.updateConfig(config) },
onTransactionClick = onTransactionClick,
onBackClick = onBackClick,
modifier = modifier,
)
@ -42,20 +49,113 @@ fun WalletRoute(
@Composable
private fun WalletScreen(
uiState: WalletUiState,
onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
onWalletConfigChange: (WalletConfig) -> Unit = {},
onBackClick: () -> Unit = {},
) {
when (uiState) {
WalletUiState.Error -> WalletScreenError(onBackClick = onBackClick)
WalletUiState.Loading -> WalletScreenLoading(onBackClick = onBackClick)
is WalletUiState.Success -> WalletScreenPopulated(
walletConfig = uiState.config,
ledger = uiState.ledger,
is WalletUiState.Loaded -> WalletScreenLoaded(
uiState = uiState,
onWalletConfigChange = onWalletConfigChange,
onTransactionClick = onTransactionClick,
onBackClick = onBackClick,
modifier = modifier,
)
WalletUiState.Error -> WalletScreenError(onBackClick = onBackClick)
WalletUiState.Loading -> WalletScreenLoading(onBackClick = onBackClick)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WalletScreenLoaded(
uiState: WalletUiState.Loaded,
onWalletConfigChange: (WalletConfig) -> Unit,
onTransactionClick: (String, Long) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var showRenameDialog by remember { mutableStateOf(false) }
Scaffold(topBar = {
Toolbar(navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
}, actions = {
WalletKebabMenu(
onRenameClick = { showRenameDialog = true },
onDeleteClick = { },
)
})
}) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(style = MaterialTheme.typography.headlineLarge, text = buildAnnotatedString {
append(MoneroCurrency.SYMBOL + " ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(
MoneroCurrency.Format(precision = 5).format(uiState.balance.confirmedAmount)
)
}
})
Text(text = uiState.config.name, style = MaterialTheme.typography.headlineSmall)
var selectedTabIndex by rememberSaveable { mutableStateOf(0) }
WalletHeaderTabs(
titles = listOf("Balance", "Transactions"),
selectedTabIndex = selectedTabIndex,
onTabSelected = { index -> selectedTabIndex = index },
)
when (selectedTabIndex) {
0 -> {
WalletBalanceView(balance = uiState.balance, blockchainTime = uiState.blockchainTime)
}
1 -> {
LazyColumn {
transactionCardItems(
items = uiState.transactions,
onTransactionClick = onTransactionClick,
)
}
}
}
}
if (showRenameDialog) {
var name by remember { mutableStateOf(uiState.config.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Enter wallet name") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
onWalletConfigChange(uiState.config.copy(name = name))
showRenameDialog = false
}) {
Text("Rename")
}
},
)
}
}
}
@ -71,85 +171,6 @@ private fun WalletScreenLoading(
) {
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun WalletScreenPopulated(
walletConfig: WalletConfig,
ledger: Ledger,
onWalletConfigChange: (WalletConfig) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var showRenameDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
Toolbar(
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = AppIcons.ArrowBack,
contentDescription = "Back",
)
}
},
actions = {
WalletKebabMenu(
onRenameClick = { showRenameDialog = true },
onDeleteClick = { },
)
}
)
}
) { padding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
style = MaterialTheme.typography.headlineLarge,
text = buildAnnotatedString {
append(MoneroCurrency.SYMBOL + " ")
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(MoneroCurrency.Format(precision = 5).format(ledger.balance.confirmedAmount))
}
}
)
Text(text = walletConfig.name, style = MaterialTheme.typography.headlineSmall)
WalletBalanceView(
balance = ledger.balance,
blockchainTime = ledger.checkedAt,
)
}
if (showRenameDialog) {
var name by remember { mutableStateOf(walletConfig.name) }
AlertDialog(
onDismissRequest = { showRenameDialog = false },
title = { Text("Enter wallet name") },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
singleLine = true,
)
},
confirmButton = {
TextButton(onClick = {
onWalletConfigChange(walletConfig.copy(name = name))
showRenameDialog = false
}) {
Text("Rename")
}
},
)
}
}
}
@Composable
private fun WalletKebabMenu(
onRenameClick: () -> Unit,
@ -184,26 +205,57 @@ private fun WalletKebabMenu(
}
}
@Composable
fun WalletHeaderTabs(
titles: List<String>,
selectedTabIndex: Int,
onTabSelected: (Int) -> Unit,
) {
Column {
TabRow(selectedTabIndex = selectedTabIndex) {
titles.forEachIndexed { index, title ->
Tab(
selected = index == selectedTabIndex,
onClick = { onTabSelected(index) },
text = {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
overflow = TextOverflow.Ellipsis,
)
},
)
}
}
}
}
@Preview
@Composable
private fun WalletScreenPreview() {
private fun WalletScreenPopulated(
@PreviewParameter(WalletScreenPreviewParameterProvider::class) ledger: Ledger,
) {
AppTheme {
WalletScreen(
uiState = WalletUiState.Success(
WalletConfig(
uiState = WalletUiState.Loaded(
config = WalletConfig(
id = 0,
publicAddress = "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
publicAddress = ledger.primaryAddress,
filename = "",
name = "Personal",
remoteNodes = emptySet(),
),
Ledger(
publicAddress = "888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H",
transactions = emptyMap(),
enotes = emptySet(),
checkedAt = BlockchainTime.Genesis,
),
balance = ledger.balance,
blockchainTime = ledger.checkedAt,
transactions = emptyList(),
),
onWalletConfigChange = {},
onTransactionClick = { _: String, _: Long -> },
onBackClick = {},
)
}
}
private class WalletScreenPreviewParameterProvider : PreviewParameterProvider<Ledger> {
override val values = sequenceOf(PreviewParameterData.ledger)
}

View File

@ -4,21 +4,23 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import im.molly.monero.Ledger
import im.molly.monero.Balance
import im.molly.monero.BlockchainTime
import im.molly.monero.demo.AppModule
import im.molly.monero.demo.common.Result
import im.molly.monero.demo.common.asResult
import im.molly.monero.demo.data.WalletRepository
import im.molly.monero.demo.data.model.WalletConfig
import im.molly.monero.demo.data.model.WalletTransaction
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class WalletViewModel(
private val walletId: Long,
walletId: Long,
private val walletRepository: WalletRepository = AppModule.walletRepository,
) : ViewModel() {
val walletUiState: StateFlow<WalletUiState> = walletUiState(
val uiState: StateFlow<WalletUiState> = walletUiState(
walletId = walletId,
walletRepository = walletRepository,
).stateIn(
@ -33,6 +35,8 @@ class WalletViewModel(
WalletViewModel(walletId)
}
}
fun key(walletId: Long): String = "wallet_$walletId"
}
fun updateConfig(config: WalletConfig) {
@ -53,11 +57,19 @@ private fun walletUiState(
).asResult().map { result ->
when (result) {
is Result.Success -> {
WalletUiState.Success(result.data.first, result.data.second)
val config = result.data.first
val ledger = result.data.second
val balance = ledger.balance
val blockchainTime = ledger.checkedAt
val transactions =
ledger.transactions.map { WalletTransaction(config.id, it.value) }
WalletUiState.Loaded(config, blockchainTime, balance, transactions)
}
is Result.Loading -> {
WalletUiState.Loading
}
is Result.Error -> {
WalletUiState.Error
}
@ -66,9 +78,11 @@ private fun walletUiState(
}
sealed interface WalletUiState {
data class Success(
data class Loaded(
val config: WalletConfig,
val ledger: Ledger,
val blockchainTime: BlockchainTime,
val balance: Balance,
val transactions: List<WalletTransaction>,
) : WalletUiState
data object Error : WalletUiState

View File

@ -12,8 +12,12 @@ fun NavController.navigateToHistory(navOptions: NavOptions? = null) {
navigate(historyNavRoute, navOptions)
}
fun NavGraphBuilder.historyScreen() {
fun NavGraphBuilder.historyScreen(
navigateToTransaction: (String, Long) -> Unit,
) {
composable(route = historyNavRoute) {
HistoryRoute()
HistoryRoute(
navigateToTransaction = navigateToTransaction,
)
}
}

View File

@ -25,13 +25,23 @@ fun NavGraph(
navController.navigateToAddWalletWizardGraph()
},
)
historyScreen()
historyScreen(
navigateToTransaction = { txId, walletId ->
navController.navigateToTransaction(txId, walletId)
},
)
settingsScreen(
navigateToEditRemoteNode = { remoteNodeId ->
navController.navigateToEditRemoteNode(remoteNodeId)
},
)
walletScreen(
navigateToTransaction = { txId, walletId ->
navController.navigateToTransaction(txId, walletId)
},
onBackClick = onBackClick,
)
transactionScreen(
onBackClick = onBackClick,
)
editRemoteNodeDialog(

View File

@ -0,0 +1,36 @@
package im.molly.monero.demo.ui.navigation
import androidx.navigation.*
import androidx.navigation.compose.composable
import im.molly.monero.demo.ui.TransactionRoute
const val transactionNavRoute = "tx"
private const val txIdArg = "txId"
private const val walletIdArg = "walletId"
fun NavController.navigateToTransaction(txId: String, walletId: Long) {
val route = "$transactionNavRoute/$txId/$walletId"
navigate(route)
}
fun NavGraphBuilder.transactionScreen(
onBackClick: () -> Unit,
) {
composable(
route = "$transactionNavRoute/{$txIdArg}/{$walletIdArg}",
arguments = listOf(
navArgument(txIdArg) { type = NavType.StringType },
navArgument(walletIdArg) { type = NavType.LongType },
)
) {
val arguments = requireNotNull(it.arguments)
val txId = arguments.getString(txIdArg)
val walletId = arguments.getLong(walletIdArg)
TransactionRoute(
txId = txId!!,
walletId = walletId,
onBackClick = onBackClick,
)
}
}

View File

@ -32,6 +32,7 @@ fun NavController.navigateToAddWalletSecondStep(restoreWallet: Boolean) {
}
fun NavGraphBuilder.walletScreen(
navigateToTransaction: (String, Long) -> Unit,
onBackClick: () -> Unit,
) {
composable(
@ -44,6 +45,7 @@ fun NavGraphBuilder.walletScreen(
val walletId = arguments.getLong(walletIdArg)
WalletRoute(
walletId = walletId,
onTransactionClick = navigateToTransaction,
onBackClick = onBackClick,
)
}

View File

@ -0,0 +1,37 @@
package im.molly.monero.demo.ui.preview
import im.molly.monero.BlockHeader
import im.molly.monero.BlockchainTime
import im.molly.monero.HashDigest
import im.molly.monero.Ledger
import im.molly.monero.MoneroAmount
import im.molly.monero.PaymentDetail
import im.molly.monero.PublicAddress
import im.molly.monero.Transaction
import im.molly.monero.TxState
import im.molly.monero.xmr
object PreviewParameterData {
val recipients =
listOf(PublicAddress.parse("888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H"))
val transactions = listOf(
Transaction(
hash = HashDigest("e7a60483591378d536792d070f2bf6ccb7d0666df03b57f485ddaf66899a294b"),
state = TxState.OnChain(BlockHeader(height = 2999840, epochSecond = 1697792826)),
timeLock = BlockchainTime.Block(2999850),
sent = emptySet(),
received = emptySet(),
payments = listOf(PaymentDetail((0.10).xmr, recipients.first())),
fee = 0.00093088.xmr,
change = MoneroAmount.ZERO,
),
)
val ledger = Ledger(
primaryAddress = "4AYjQM9HoAFNUeC3cvSfgeAN89oMMpMqiByvunzSzhn97cj726rJj3x8hCbH58UnMqQJShczCxbpWRiCJQ3HCUDHLiKuo4T",
checkedAt = BlockchainTime.Block(2999840),
enotes = emptySet(),
transactions = transactions.associateBy { it.txId },
)
}

View File

@ -6,14 +6,18 @@ data class AccountAddress(
val subAddressIndex: Int = 0,
) : PublicAddress by publicAddress {
val isPrimaryAddress: Boolean
get() = accountIndex == 0 && subAddressIndex == 0
init {
when (publicAddress) {
is SubAddress -> require(accountIndex != -1 && subAddressIndex != -1)
else -> require(accountIndex == 0 && subAddressIndex == 0)
is StandardAddress -> require(isPrimaryAddress) {
"Standard addresses must have subaddress indices set to zero"
}
is SubAddress -> require(accountIndex != -1 && subAddressIndex != -1) {
"Invalid subaddress indices"
}
else -> throw IllegalArgumentException("Unsupported address type")
}
}
fun belongsTo(targetAccountIndex: Int): Boolean {
return accountIndex == targetAccountIndex
}
}

View File

@ -9,7 +9,8 @@ class Enote(
val sourceTxId: String?,
) {
init {
require(age >= 0) { "Enote age $age must not be negative" }
require(amount > 0) { "Amount must be greater than 0" }
require(age >= 0) { "Age cannot be negative" }
}
var spent: Boolean = false

View File

@ -3,7 +3,7 @@ package im.molly.monero
//import im.molly.monero.proto.LedgerProto
data class Ledger(
val publicAddress: String,
val primaryAddress: String,
val transactions: Map<String, Transaction>,
val enotes: Set<TimeLocked<Enote>>,
val checkedAt: BlockchainTime,

View File

@ -15,7 +15,8 @@ value class MoneroAmount(val atomicUnits: Long) : Parcelable {
val ZERO = MoneroAmount(0)
}
fun toXmr(): BigDecimal = BigDecimal.valueOf(atomicUnits, ATOMIC_UNIT_SCALE)
val xmr: BigDecimal
get() = BigDecimal.valueOf(atomicUnits, ATOMIC_UNIT_SCALE)
override fun toString() = atomicUnits.toString()

View File

@ -8,10 +8,8 @@ object MoneroCurrency {
const val MAX_PRECISION = MoneroAmount.ATOMIC_UNIT_SCALE
val DefaultFormat = Format()
data class Format(
val precision: Int = MAX_PRECISION,
open class Format(
val precision: Int,
val locale: Locale = Locale.US,
) {
init {
@ -24,10 +22,36 @@ object MoneroCurrency {
minimumFractionDigits = precision
}
fun format(amount: MoneroAmount): String = numberFormat.format(amount.toXmr())
open fun format(amount: MoneroAmount): String {
return numberFormat.format(amount.xmr)
}
fun parse(source: String): MoneroAmount {
open fun parse(source: String): MoneroAmount {
TODO()
}
}
val ExactFormat = object : Format(MoneroAmount.ATOMIC_UNIT_SCALE) {
override fun format(amount: MoneroAmount) = buildString {
val num = amount.atomicUnits.toString()
if (precision < num.length) {
val point = num.length - precision
append(num.substring(0, point))
append('.')
append(num.substring(point))
} else {
append("0.")
for (i in 1..(precision - num.length)) {
append('0')
}
append(num)
}
}
}
fun format(amount: MoneroAmount, outputFormat: Format = ExactFormat): String {
return outputFormat.format(amount)
}
}

View File

@ -2,7 +2,8 @@ package im.molly.monero
import im.molly.monero.util.decodeBase58
interface PublicAddress {
sealed interface PublicAddress {
val address: String
val network: MoneroNetwork
val subAddress: Boolean
// viewPublicKey: ByteArray
@ -19,19 +20,18 @@ interface PublicAddress {
throw InvalidAddress("Address too short")
}
val prefix = decoded[0].toLong()
StandardAddress.prefixes[prefix]?.let { network ->
return StandardAddress(network)
return when (val prefix = decoded[0].toLong()) {
in StandardAddress.prefixes -> {
StandardAddress(publicAddress, StandardAddress.prefixes[prefix]!!)
}
in SubAddress.prefixes -> {
SubAddress(publicAddress, SubAddress.prefixes[prefix]!!)
}
in IntegratedAddress.prefixes -> {
TODO()
}
else -> throw InvalidAddress("Unrecognized address prefix")
}
SubAddress.prefixes[prefix]?.let { network ->
return SubAddress(network)
}
IntegratedAddress.prefixes[prefix]?.let { network ->
TODO()
}
throw InvalidAddress("Unrecognized address prefix")
}
}
}
@ -39,6 +39,7 @@ interface PublicAddress {
class InvalidAddress(message: String, cause: Throwable? = null) : Exception(message, cause)
data class StandardAddress(
override val address: String,
override val network: MoneroNetwork,
) : PublicAddress {
override val subAddress = false
@ -50,9 +51,12 @@ data class StandardAddress(
24L to MoneroNetwork.Stagenet,
)
}
override fun toString(): String = address
}
data class SubAddress(
override val address: String,
override val network: MoneroNetwork,
) : PublicAddress {
override val subAddress = true
@ -64,9 +68,12 @@ data class SubAddress(
36L to MoneroNetwork.Stagenet,
)
}
override fun toString(): String = address
}
data class IntegratedAddress(
override val address: String,
override val network: MoneroNetwork,
val paymentId: Long,
) : PublicAddress {
@ -79,4 +86,6 @@ data class IntegratedAddress(
25L to MoneroNetwork.Stagenet,
)
}
override fun toString(): String = address
}