From d6194ebfd109dfb2cafc5f32739e345f87d78938 Mon Sep 17 00:00:00 2001 From: Iliyan Germanov Date: Sat, 15 Apr 2023 23:10:15 +0300 Subject: [PATCH] WIP: ParseStatus.kt --- .../java/com/ivy/wallet/ui/csv/CSVScreen.kt | 47 +++-- .../java/com/ivy/wallet/ui/csv/CSVState.kt | 10 +- .../com/ivy/wallet/ui/csv/CSVViewModel.kt | 171 ++++++++++++++++-- .../ivy/wallet/ui/csv/domain/ParseStatus.kt | 87 +++++++++ 4 files changed, 283 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseStatus.kt diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/CSVScreen.kt b/app/src/main/java/com/ivy/wallet/ui/csv/CSVScreen.kt index d38c423c4e..8bb9e97b68 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csv/CSVScreen.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csv/CSVScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Text @@ -121,9 +122,10 @@ private fun CSVRow( row: CSVRow, header: Boolean, even: Boolean, + modifier: Modifier = Modifier, ) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() ) { row.values.forEach { value -> @@ -158,6 +160,7 @@ private fun CSVCell( private fun LazyListScope.mappingRow( columns: CSVRow, mapping: ColumnMapping, + status: MappingStatus, onMapTo: (Int, String) -> Unit, metadataContent: (@Composable (M) -> Unit)? = null, ) { @@ -169,19 +172,27 @@ private fun LazyListScope.mappingRow( .border( width = 2.dp, color = when { - mapping.required && !mapping.success -> UI.colors.red - mapping.success -> UI.colors.green + mapping.required && !status.success -> UI.colors.red + status.success -> UI.colors.green else -> UI.colors.medium - } + }, + shape = RoundedCornerShape(4.dp), ) - .padding(vertical = 8.dp, horizontal = 4.dp) + .padding(vertical = 8.dp, horizontal = 8.dp) ) { Text( text = mapping.ivyColumn, style = UI.typo.b1.colorAs(UI.colors.primary), ) + Spacer(modifier = Modifier.height(4.dp)) + Text(text = mapping.helpInfo, style = UI.typo.c) Spacer8() + Text(text = "Choose a column:", style = UI.typo.b2) + Spacer(modifier = Modifier.height(4.dp)) Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically ) { columns.values.forEachIndexed { index, column -> @@ -200,9 +211,13 @@ private fun LazyListScope.mappingRow( metadataContent(mapping.metadata) } - if (mapping.sampleValues.isNotEmpty()) { + if (status.sampleValues.isNotEmpty()) { Spacer8() - CSVRow(row = CSVRow(mapping.sampleValues), header = false, even = true) + CSVRow( + modifier = Modifier.horizontalScroll(rememberScrollState()), + row = CSVRow(status.sampleValues), + header = false, even = true + ) } } } @@ -226,6 +241,7 @@ fun LazyListScope.important( mappingRow( columns = columns, mapping = importantFields.amount, + status = importantFields.amountStatus, onMapTo = { index, name -> onEvent(CSVEvent.MapAmount(index, name)) }, metadataContent = { multiplier -> AmountMetadata(multiplier = multiplier, onEvent = onEvent) @@ -234,6 +250,7 @@ fun LazyListScope.important( mappingRow( columns = columns, mapping = importantFields.type, + status = importantFields.typeStatus, onMapTo = { index, name -> onEvent(CSVEvent.MapType(index, name)) }, metadataContent = { TypeMetadata(metadata = it, onEvent = onEvent) @@ -242,6 +259,7 @@ fun LazyListScope.important( mappingRow( columns = columns, mapping = importantFields.date, + status = importantFields.dateStatus, onMapTo = { index, name -> onEvent(CSVEvent.MapDate(index, name)) }, metadataContent = { DateMetadataUI(metadata = it, onEvent = onEvent) @@ -250,11 +268,13 @@ fun LazyListScope.important( mappingRow( columns = columns, mapping = importantFields.account, + status = importantFields.accountStatus, onMapTo = { index, name -> onEvent(CSVEvent.MapAccount(index, name)) }, ) mappingRow( columns = columns, mapping = importantFields.accountCurrency, + status = importantFields.accountCurrencyStatus, onMapTo = { index, name -> onEvent(CSVEvent.MapAccountCurrency(index, name)) }, ) } @@ -313,7 +333,7 @@ private fun TypeMetadata( onEvent(CSVEvent.TypeMetaChange(newMeta)) } - LabelEqualsField( + LabelContainsField( label = "Income", value = metadata.income, onValueChange = { @@ -321,7 +341,7 @@ private fun TypeMetadata( } ) Spacer8() - LabelEqualsField( + LabelContainsField( label = "Expense", value = metadata.expense, onValueChange = { @@ -330,7 +350,7 @@ private fun TypeMetadata( ) Spacer8() Text(text = "(optional)", style = UI.typo.c) - LabelEqualsField( + LabelContainsField( label = "Transfer", value = metadata.transfer ?: "", onValueChange = { @@ -340,15 +360,16 @@ private fun TypeMetadata( } @Composable -fun LabelEqualsField( +fun LabelContainsField( label: String, value: String, onValueChange: (String) -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = label, color = UI.colors.primary) + Text(text = label, color = UI.colors.primary, style = UI.typo.nB1) + Text(text = " contains ", style = UI.typo.c) Spacer8(horizontal = true) - TextField(value = value, onValueChange = onValueChange) + TextField(value = value, onValueChange = onValueChange, singleLine = true) } } // endregion diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/CSVState.kt b/app/src/main/java/com/ivy/wallet/ui/csv/CSVState.kt index 0cdfbc8d63..382eb275e1 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csv/CSVState.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csv/CSVState.kt @@ -13,10 +13,15 @@ data class CSVState( data class ImportantFields( val amount: ColumnMapping, + val amountStatus: MappingStatus, val type: ColumnMapping, + val typeStatus: MappingStatus, val date: ColumnMapping, + val dateStatus: MappingStatus, val account: ColumnMapping, + val accountStatus: MappingStatus, val accountCurrency: ColumnMapping, + val accountCurrencyStatus: MappingStatus, ) data class TrnTypeMetadata( @@ -45,9 +50,12 @@ data class ColumnMapping( val helpInfo: String, val name: String, val index: Int, - val sampleValues: List, val metadata: M, val required: Boolean, +) + +data class MappingStatus( + val sampleValues: List, val success: Boolean, ) diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/CSVViewModel.kt b/app/src/main/java/com/ivy/wallet/ui/csv/CSVViewModel.kt index 93cd24d97c..41f6eef1b2 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csv/CSVViewModel.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csv/CSVViewModel.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ivy.wallet.domain.deprecated.logic.csv.IvyFileReader +import com.ivy.wallet.ui.csv.domain.mappingFailure +import com.ivy.wallet.ui.csv.domain.parseImportantStatus import com.opencsv.CSVReaderBuilder import com.opencsv.validators.LineValidator import com.opencsv.validators.RowValidator @@ -27,40 +29,173 @@ class CSVViewModel @Inject constructor( private var columns by mutableStateOf(null) private var csv by mutableStateOf?>(null) - private var important by mutableStateOf(null) private var transfer by mutableStateOf(null) private var optional by mutableStateOf(null) private var successPercent by mutableStateOf(null) private var failedRows by mutableStateOf?>(null) - @Composable - fun uiState(): CSVState = CSVState( - columns = columns, - csv = csv, - important = important, - transfer = transfer, - optional = optional, - successPercent = successPercent, - failedRows = failedRows, + private var amount by mutableStateOf( + ColumnMapping( + ivyColumn = "Amount", + helpInfo = "The amount of the transactions, a positive number. Negative numbers will be made positive.", + name = "", + index = -1, + metadata = 1, + required = true, + ) + ) + private var type by mutableStateOf( + ColumnMapping( + ivyColumn = "Transaction Type", + helpInfo = """ + The type of the transaction. Can be Income, Expense or a Transfer. + """.trimIndent(), + name = "", + index = -1, + required = true, + metadata = TrnTypeMetadata( + income = "", + expense = "", + transfer = null, + ) + ) + ) + private var date by mutableStateOf( + ColumnMapping( + ivyColumn = "Date", + helpInfo = """ + The date of the transaction. To help us parse it just tell us + whether the Date or the Month comes first. + """.trimIndent(), + name = "", + index = -1, + required = true, + metadata = DateMetadata.DateFirst + ) + ) + private var account by mutableStateOf( + ColumnMapping( + ivyColumn = "Account", + helpInfo = """ + The account of the transaction. + """.trimIndent(), + name = "", + index = -1, + required = true, + metadata = Unit, + ) + ) + private var accountCurrency by mutableStateOf( + ColumnMapping( + ivyColumn = "Account Currency", + helpInfo = """ + The currency of the account that made the transaction. + In Ivy Wallet, transactions don't have a currency but inherit + the ones from their account. + """.trimIndent(), + name = "", + index = -1, + required = true, + metadata = Unit, + ) ) + @Composable + fun uiState(): CSVState { + return CSVState( + columns = columns, + csv = csv, + important = important(csv), + transfer = transfer, + optional = optional, + successPercent = successPercent, + failedRows = failedRows, + ) + } + + @Composable + private fun important(csv: List?): ImportantFields? { + val failure = mappingFailure() + val importantFields = ImportantFields( + amount = amount, + amountStatus = failure, + type = type, + typeStatus = failure, + date = date, + dateStatus = failure, + account = account, + accountStatus = failure, + accountCurrency = accountCurrency, + accountCurrencyStatus = failure, + ) + + return if (csv != null) { + val status = parseImportantStatus(csv, importantFields) + importantFields.copy( + amountStatus = status.amountStatus, + typeStatus = status.typeStatus, + dateStatus = status.dateStatus, + accountStatus = status.accountStatus, + accountCurrencyStatus = status.accountCurrencyStatus, + ) + } else null + } + private suspend fun handleEvent(event: CSVEvent) { when (event) { is CSVEvent.FilePicked -> handleFilePicked(event) - is CSVEvent.AmountMultiplier -> TODO() - is CSVEvent.DataMetaChange -> TODO() - is CSVEvent.MapAccount -> TODO() - is CSVEvent.MapAccountCurrency -> TODO() - is CSVEvent.MapAmount -> TODO() - is CSVEvent.MapDate -> TODO() - is CSVEvent.MapType -> TODO() - is CSVEvent.TypeMetaChange -> TODO() + is CSVEvent.AmountMultiplier -> { + amount = amount.copy( + metadata = event.multiplier, + ) + } + is CSVEvent.DataMetaChange -> { + date = date.copy( + metadata = event.meta + ) + } + is CSVEvent.MapAccount -> { + account = account.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapAccountCurrency -> { + accountCurrency = accountCurrency.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapAmount -> { + amount = amount.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapDate -> { + date = date.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapType -> { + type = type.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.TypeMetaChange -> { + type = type.copy( + metadata = event.meta + ) + } } } private suspend fun handleFilePicked(event: CSVEvent.FilePicked) = withContext(Dispatchers.IO) { csv = processFile(event.uri) + columns = csv?.firstOrNull() } private suspend fun processFile( diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseStatus.kt b/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseStatus.kt new file mode 100644 index 0000000000..e0d1c0d76a --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseStatus.kt @@ -0,0 +1,87 @@ +package com.ivy.wallet.ui.csv.domain + +import com.ivy.wallet.domain.data.TransactionType +import com.ivy.wallet.ui.csv.* +import kotlin.math.abs + + +data class ImportantStatus( + val amountStatus: MappingStatus, + val typeStatus: MappingStatus, + val dateStatus: MappingStatus, + val accountStatus: MappingStatus, + val accountCurrencyStatus: MappingStatus, +) + +fun parseImportantStatus( + csv: List, + important: ImportantFields, +): ImportantStatus { + val rows = csv.drop(1).take(10) // drop the header + + return ImportantStatus( + amountStatus = parseAmountStatus(rows, important.amount), + typeStatus = parseTypeStatus(rows, important.type), + dateStatus = mappingFailure(), + accountStatus = mappingFailure(), + accountCurrencyStatus = mappingFailure(), + ) +} + +private fun parseAmountStatus( + rows: List, + mapping: ColumnMapping +): MappingStatus = tryStatus { + val values = rows.values(mapping) + .mapNotNull { + it.toDoubleOrNull()?.plus(mapping.metadata)?.let(::abs) + } + + MappingStatus( + sampleValues = values.map { it.toString() }, + success = values.size == rows.size + ) +} + +private fun parseTypeStatus( + rows: List, + mapping: ColumnMapping +): MappingStatus = tryStatus { + fun String.tryMeta(metaContains: String): Boolean { + return metaContains.isNotBlank() && + this.contains(metaContains.trim(), ignoreCase = true) + } + + val values = rows.values(mapping) + .mapNotNull { value -> + val meta = mapping.metadata + with(value) { + when { + tryMeta(meta.expense) -> TransactionType.EXPENSE + tryMeta(meta.income) -> TransactionType.INCOME + tryMeta(meta.transfer ?: "") -> TransactionType.TRANSFER + value.contains("income", ignoreCase = true) -> TransactionType.INCOME + value.contains("expense", ignoreCase = true) -> TransactionType.EXPENSE + value.contains("transfer", ignoreCase = true) -> TransactionType.TRANSFER + else -> null + } + } + } + + MappingStatus( + sampleValues = values.map { it.toString() }, + success = values.size == rows.size + ) +} + + +private fun List.values(mapping: ColumnMapping): List = + map { it.values[mapping.index] } + +private fun tryStatus(block: () -> MappingStatus): MappingStatus = try { + block() +} catch (e: Exception) { + MappingStatus(sampleValues = emptyList(), success = false) +} + +fun mappingFailure(): MappingStatus = MappingStatus(sampleValues = emptyList(), success = false) \ No newline at end of file