From f051ae38b08384f0c40853dc66f50e736970dd8a Mon Sep 17 00:00:00 2001 From: Iliyan Germanov Date: Sun, 16 Apr 2023 00:41:35 +0300 Subject: [PATCH] WIP: CSV parser --- .../java/com/ivy/wallet/ui/csv/CSVEvent.kt | 10 + .../java/com/ivy/wallet/ui/csv/CSVScreen.kt | 73 ++++++- .../java/com/ivy/wallet/ui/csv/CSVState.kt | 9 +- .../com/ivy/wallet/ui/csv/CSVViewModel.kt | 201 ++++++++++++++++-- .../csv/domain/{Parser.kt => ParseFields.kt} | 26 +++ 5 files changed, 294 insertions(+), 25 deletions(-) rename app/src/main/java/com/ivy/wallet/ui/csv/domain/{Parser.kt => ParseFields.kt} (87%) diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/CSVEvent.kt b/app/src/main/java/com/ivy/wallet/ui/csv/CSVEvent.kt index 76e929653c..9a099db273 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csv/CSVEvent.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csv/CSVEvent.kt @@ -13,4 +13,14 @@ sealed interface CSVEvent { data class DataMetaChange(val meta: DateMetadata) : CSVEvent data class MapAccount(val index: Int, val name: String) : CSVEvent data class MapAccountCurrency(val index: Int, val name: String) : CSVEvent + + data class MapToAccount(val index: Int, val name: String) : CSVEvent + data class MapToAccountCurrency(val index: Int, val name: String) : CSVEvent + + data class MapCategory(val index: Int, val name: String) : CSVEvent + data class MapTitle(val index: Int, val name: String) : CSVEvent + data class MapDescription(val index: Int, val name: String) : CSVEvent + + object Continue : CSVEvent + } \ No newline at end of file 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 09d69d33fd..5f51cef028 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 @@ -59,7 +59,13 @@ private fun UI( csvTable(state.csv) } if (state.columns != null && state.important != null) { - important(state.columns, importantFields = state.important, onEvent = onEvent) + importantFields(state.columns, importantFields = state.important, onEvent = onEvent) + } + if (state.columns != null && state.transfer != null) { + transferFields(state.columns, state.transfer, onEvent = onEvent) + } + if (state.columns != null && state.optional != null) { + optionalFields(state.columns, state.optional, onEvent = onEvent) } } } @@ -225,14 +231,22 @@ private fun LazyListScope.mappingRow( fun LazyListScope.sectionDivider(text: String) { item { - Spacer8() - Text(text = text, style = UI.typo.h2) + Spacer(modifier = Modifier.height(24.dp)) + Text(text = text, style = UI.typo.b1) + Text( + text = """ + Match the CSV column with the appropriate Ivy type. + If the parsing is successful the border will turn green. + """.trimIndent(), + style = UI.typo.nB2, + color = UI.colors.pureInverse + ) Spacer8() } } // region Important -fun LazyListScope.important( +fun LazyListScope.importantFields( columns: CSVRow, importantFields: ImportantFields, onEvent: (CSVEvent) -> Unit @@ -419,4 +433,53 @@ private fun EnabledButton( Text(text = text) } } -// endregion \ No newline at end of file +// endregion + +fun LazyListScope.transferFields( + columns: CSVRow, + transferFields: TransferFields, + onEvent: (CSVEvent) -> Unit +) { + sectionDivider("Transfer fields") + mappingRow( + columns = columns, + mapping = transferFields.toAccount, + status = transferFields.toAccountStatus, + onMapTo = { index, name -> onEvent(CSVEvent.MapToAccount(index, name)) }, + ) + mappingRow( + columns = columns, + mapping = transferFields.toAccountCurrency, + status = transferFields.toAccountCurrencyStatus, + onMapTo = { index, name -> onEvent(CSVEvent.MapToAccountCurrency(index, name)) }, + ) +} + +fun LazyListScope.optionalFields( + columns: CSVRow, + optionalFields: OptionalFields, + onEvent: (CSVEvent) -> Unit +) { + sectionDivider("Optional fields") + mappingRow( + columns = columns, + mapping = optionalFields.category, + status = optionalFields.categoryStatus, + onMapTo = { index, name -> onEvent(CSVEvent.MapCategory(index, name)) }, + ) + mappingRow( + columns = columns, + mapping = optionalFields.title, + status = optionalFields.titleStatus, + onMapTo = { index, name -> onEvent(CSVEvent.MapTitle(index, name)) }, + ) + mappingRow( + columns = columns, + mapping = optionalFields.description, + status = optionalFields.descriptionStatus, + onMapTo = { index, name -> onEvent(CSVEvent.MapDescription(index, name)) }, + ) +} + + + 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 382eb275e1..f2cded5029 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 @@ -6,9 +6,7 @@ data class CSVState( val important: ImportantFields?, val transfer: TransferFields?, val optional: OptionalFields?, - - val successPercent: Double?, - val failedRows: List?, + val continueEnabled: Boolean, ) data class ImportantFields( @@ -36,13 +34,18 @@ enum class DateMetadata { data class TransferFields( val toAccount: ColumnMapping, + val toAccountStatus: MappingStatus, val toAccountCurrency: ColumnMapping, + val toAccountCurrencyStatus: MappingStatus, ) data class OptionalFields( val category: ColumnMapping, + val categoryStatus: MappingStatus, val title: ColumnMapping, + val titleStatus: MappingStatus, val description: ColumnMapping, + val descriptionStatus: MappingStatus, ) data class ColumnMapping( 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 e5bdd89e15..7afa4f9393 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 @@ -25,9 +25,8 @@ class CSVViewModel @Inject constructor( private var columns by mutableStateOf(null) private var csv by mutableStateOf?>(null) - private var successPercent by mutableStateOf(null) - private var failedRows by mutableStateOf?>(null) + // region Important fields private var amount by mutableStateOf( ColumnMapping( ivyColumn = "Amount", @@ -42,7 +41,10 @@ class CSVViewModel @Inject constructor( ColumnMapping( ivyColumn = "Transaction Type", helpInfo = """ + Select the column that determines the transaction type. The type of the transaction. Can be Income, Expense or a Transfer. + If the type is determined by the transaction's amount -> simply select the + amount column and we'll do our best to match it automatically. """.trimIndent(), name = "", index = -1, @@ -89,44 +91,126 @@ class CSVViewModel @Inject constructor( """.trimIndent(), name = "", index = -1, - required = true, + required = false, + metadata = Unit, + ) + ) + // endregion + + // region Transfer fields + private var toAccount by mutableStateOf( + ColumnMapping( + ivyColumn = "To Account", + helpInfo = """ + The account receiving the transfer. + If you skip it, transfers won't be imported. + """.trimIndent(), + name = "", + index = -1, + required = false, + metadata = Unit, + ) + ) + private var toAccountCurrency by mutableStateOf( + ColumnMapping( + ivyColumn = "To Account Currency", + helpInfo = """ + The currency of the account that receives the transfer. + Skip it if there's no such. + """.trimIndent(), + name = "", + index = -1, + required = false, + metadata = Unit, + ) + ) + // endregion + + // region Optional fields + private var category by mutableStateOf( + ColumnMapping( + ivyColumn = "Category", + helpInfo = """ + The category of the transaction. + """.trimIndent(), + name = "", + index = -1, + required = false, metadata = Unit, ) ) + private var title by mutableStateOf( + ColumnMapping( + ivyColumn = "Title", + helpInfo = """ + The title of the transaction. + """.trimIndent(), + name = "", + index = -1, + required = false, + metadata = Unit, + ) + ) + private var description by mutableStateOf( + ColumnMapping( + ivyColumn = "Description", + helpInfo = """ + The description of the transaction. + """.trimIndent(), + name = "", + index = -1, + required = false, + metadata = Unit, + ) + ) + // endregion + @Composable fun uiState(): CSVState { + val sampleCSV = remember(csv) { + // drop the header + csv?.drop(1)?.take(10) + } + + val important = importantFields(sampleCSV) return CSVState( columns = columns, csv = csv, - important = important(csv), - transfer = null, - optional = null, - successPercent = successPercent, - failedRows = failedRows, + important = important, + transfer = transferFields(sampleCSV), + optional = optionalFields(sampleCSV), + continueEnabled = continueEnabled(important = important) ) } @Composable - private fun important(csv: List?): ImportantFields? { + private fun continueEnabled(important: ImportantFields?): Boolean { + return important != null && important.accountStatus.success && + important.amountStatus.success && + important.typeStatus.success && + important.dateStatus.success + } + + @Composable + private fun importantFields(sampleCSV: List?): ImportantFields? { return produceState( initialValue = null, - csv, amount, type, date, account, accountCurrency, + sampleCSV, amount, type, date, account, accountCurrency, ) { val result = withContext(Dispatchers.Default) { - if (csv != null) { - val sampleRows = csv.drop(1).take(10) // drop the header + if (sampleCSV != null) { ImportantFields( amount = amount, - amountStatus = sampleRows.parseStatus(amount, ::parseAmount), + amountStatus = sampleCSV.parseStatus(amount, ::parseAmount), type = type, - typeStatus = sampleRows.parseStatus(type, ::parseTransactionType), + typeStatus = sampleCSV.parseStatus(type, ::parseTransactionType), date = date, - dateStatus = sampleRows.parseStatus(date, ::parseDate), + dateStatus = sampleCSV.parseStatus(date, ::parseDate), account = account, - accountStatus = sampleRows.parseStatus(account, ::parseAccount), + accountStatus = sampleCSV.parseStatus(account, ::parseAccount), accountCurrency = accountCurrency, - accountCurrencyStatus = sampleRows.parseStatus( + accountCurrencyStatus = sampleCSV.parseStatus( accountCurrency, ::parseAccountCurrency ), @@ -137,6 +221,51 @@ class CSVViewModel @Inject constructor( }.value } + @Composable + private fun transferFields(sampleCSV: List?): TransferFields? { + return produceState( + initialValue = null, + sampleCSV, toAccount, toAccountCurrency, + ) { + val result = withContext(Dispatchers.Default) { + if (sampleCSV != null) { + TransferFields( + toAccount = toAccount, + toAccountStatus = sampleCSV.parseStatus(toAccount, ::parseToAccount), + toAccountCurrency = toAccountCurrency, + toAccountCurrencyStatus = sampleCSV.parseStatus( + toAccountCurrency, + ::parseToAccountCurrency + ), + ) + } else null + } + value = result + }.value + } + + @Composable + private fun optionalFields(sampleCSV: List?): OptionalFields? { + return produceState( + initialValue = null, + sampleCSV, toAccount, toAccountCurrency, + ) { + val result = withContext(Dispatchers.Default) { + if (sampleCSV != null) { + OptionalFields( + category = category, + categoryStatus = sampleCSV.parseStatus(category, ::parseCategory), + title = title, + titleStatus = sampleCSV.parseStatus(title, ::parseTitle), + description = description, + descriptionStatus = sampleCSV.parseStatus(description, ::parseDescription), + ) + } else null + } + value = result + }.value + } + private suspend fun handleEvent(event: CSVEvent) { when (event) { @@ -186,9 +315,41 @@ class CSVViewModel @Inject constructor( metadata = event.meta ) } + is CSVEvent.MapCategory -> { + category = category.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapDescription -> { + description = description.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapTitle -> { + title = title.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapToAccount -> { + toAccount = toAccount.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.MapToAccountCurrency -> { + toAccountCurrency = toAccountCurrency.copy( + index = event.index, + name = event.name + ) + } + CSVEvent.Continue -> handleContinue() } } + // region Import CSV private suspend fun handleFilePicked(event: CSVEvent.FilePicked) = withContext(Dispatchers.IO) { csv = processFile(event.uri) columns = csv?.firstOrNull() @@ -235,6 +396,12 @@ class CSVViewModel @Inject constructor( return csvReader.readAll() .map { CSVRow(it.toList()) } } + // endregion + + suspend private fun handleContinue() { + + } + // region Boiler-plate diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/domain/Parser.kt b/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseFields.kt similarity index 87% rename from app/src/main/java/com/ivy/wallet/ui/csv/domain/Parser.kt rename to app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseFields.kt index a0a95fed98..99c1ae7914 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csv/domain/Parser.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseFields.kt @@ -44,6 +44,8 @@ fun parseTransactionType( value.contains("income", ignoreCase = true) -> TransactionType.INCOME value.contains("expense", ignoreCase = true) -> TransactionType.EXPENSE value.contains("transfer", ignoreCase = true) -> TransactionType.TRANSFER + value.toDoubleOrNull()?.let { it > 0 } == true -> TransactionType.INCOME + value.toDoubleOrNull()?.let { it < 0 } == true -> TransactionType.EXPENSE else -> null } } @@ -151,6 +153,30 @@ fun parseAccountCurrency( metadata: Unit, ): String? = notBlankTrimmedString(value) +fun parseToAccount( + value: String, + metadata: Unit +): String? = notBlankTrimmedString(value) + +fun parseToAccountCurrency( + value: String, + metadata: Unit +): String? = notBlankTrimmedString(value) + +fun parseCategory( + value: String, + metadata: Unit +): String? = notBlankTrimmedString(value) + +fun parseTitle( + value: String, + metadata: Unit +): String? = notBlankTrimmedString(value) + +fun parseDescription( + value: String, + metadata: Unit +): String? = notBlankTrimmedString(value) // region Util