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 9a099db273..f07e5c8ae3 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 @@ -16,6 +16,8 @@ sealed interface CSVEvent { data class MapToAccount(val index: Int, val name: String) : CSVEvent data class MapToAccountCurrency(val index: Int, val name: String) : CSVEvent + data class MapToAmount(val index: Int, val name: String) : CSVEvent + data class ToAmountMetaChange(val multiplier: Int) : CSVEvent data class MapCategory(val index: Int, val name: String) : CSVEvent data class MapTitle(val index: Int, val name: String) : CSVEvent 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 2758fc5dd6..f9294752a8 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 @@ -259,7 +259,9 @@ fun LazyListScope.importantFields( status = importantFields.amountStatus, onMapTo = { index, name -> onEvent(CSVEvent.MapAmount(index, name)) }, metadataContent = { multiplier -> - AmountMetadata(multiplier = multiplier, onEvent = onEvent) + AmountMetadata(multiplier = multiplier, onMetaChange = { + onEvent(CSVEvent.AmountMultiplier(it)) + }) } ) mappingRow( @@ -297,18 +299,16 @@ fun LazyListScope.importantFields( @Composable private fun AmountMetadata( multiplier: Int, - onEvent: (CSVEvent) -> Unit, + onMetaChange: (Int) -> Unit, ) { Row(verticalAlignment = Alignment.CenterVertically) { Button(onClick = { - onEvent( - CSVEvent.AmountMultiplier( - when { - multiplier < 0 -> multiplier * 10 - multiplier == 1 -> -10 - else -> multiplier / 10 - } - ) + onMetaChange( + when { + multiplier < 0 -> multiplier * 10 + multiplier == 1 -> -10 + else -> multiplier / 10 + } ) }) { Text(text = "/10") @@ -325,15 +325,13 @@ private fun AmountMetadata( ) Spacer8(horizontal = true) Button(onClick = { - onEvent( - CSVEvent.AmountMultiplier( - when { - multiplier == -10 -> 1 - multiplier == -1 -> 10 - multiplier > 0 -> multiplier * 10 - else -> multiplier / 10 - } - ) + onMetaChange( + when { + multiplier == -10 -> 1 + multiplier == -1 -> 10 + multiplier > 0 -> multiplier * 10 + else -> multiplier / 10 + } ) }) { Text(text = "*10") @@ -454,6 +452,17 @@ fun LazyListScope.transferFields( status = transferFields.toAccountCurrencyStatus, onMapTo = { index, name -> onEvent(CSVEvent.MapToAccountCurrency(index, name)) }, ) + mappingRow( + columns = columns, + mapping = transferFields.toAmount, + status = transferFields.toAmountStatus, + onMapTo = { index, name -> onEvent(CSVEvent.MapToAmount(index, name)) }, + metadataContent = { multiplier -> + AmountMetadata(multiplier = multiplier, onMetaChange = { + onEvent(CSVEvent.ToAmountMetaChange(it)) + }) + } + ) } fun LazyListScope.optionalFields( 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 f2cded5029..ed4a886210 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 @@ -37,6 +37,8 @@ data class TransferFields( val toAccountStatus: MappingStatus, val toAccountCurrency: ColumnMapping, val toAccountCurrencyStatus: MappingStatus, + val toAmount: ColumnMapping, + val toAmountStatus: MappingStatus, ) data class OptionalFields( 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 c31e40d49e..cdbd1b4f90 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 @@ -21,6 +21,7 @@ import javax.inject.Inject @HiltViewModel class CSVViewModel @Inject constructor( private val fileReader: IvyFileReader, + private val csvImporterV2: CSVImporterV2, ) : ViewModel() { private var columns by mutableStateOf(null) @@ -124,6 +125,19 @@ class CSVViewModel @Inject constructor( metadata = Unit, ) ) + private var toAmount by mutableStateOf( + ColumnMapping( + ivyColumn = "To Amount", + helpInfo = """ + The amount that the "To Account" will receive". + Skip it if there's no such. + """.trimIndent(), + name = "", + index = -1, + required = false, + metadata = 1, + ) + ) // endregion // region Optional fields @@ -237,6 +251,8 @@ class CSVViewModel @Inject constructor( toAccountCurrency, ::parseToAccountCurrency ), + toAmount = toAmount, + toAmountStatus = sampleCSV.parseStatus(toAmount, ::parseAmount) ) } else null } @@ -346,6 +362,17 @@ class CSVViewModel @Inject constructor( ) } CSVEvent.Continue -> handleContinue() + is CSVEvent.MapToAmount -> { + toAmount = toAmount.copy( + index = event.index, + name = event.name + ) + } + is CSVEvent.ToAmountMetaChange -> { + toAmount = toAmount.copy( + metadata = event.multiplier + ) + } } } diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/domain/CSVImporterV2.kt b/app/src/main/java/com/ivy/wallet/ui/csv/domain/CSVImporterV2.kt new file mode 100644 index 0000000000..04ed243987 --- /dev/null +++ b/app/src/main/java/com/ivy/wallet/ui/csv/domain/CSVImporterV2.kt @@ -0,0 +1,315 @@ +package com.ivy.wallet.ui.csv.domain + +import androidx.compose.ui.graphics.toArgb +import com.ivy.wallet.domain.data.IvyCurrency +import com.ivy.wallet.domain.data.TransactionType +import com.ivy.wallet.domain.data.core.Account +import com.ivy.wallet.domain.data.core.Category +import com.ivy.wallet.domain.data.core.Transaction +import com.ivy.wallet.domain.deprecated.logic.csv.model.CSVRow +import com.ivy.wallet.domain.deprecated.logic.csv.model.ImportResult +import com.ivy.wallet.domain.pure.util.nextOrderNum +import com.ivy.wallet.io.persistence.dao.AccountDao +import com.ivy.wallet.io.persistence.dao.CategoryDao +import com.ivy.wallet.io.persistence.dao.SettingsDao +import com.ivy.wallet.io.persistence.dao.TransactionDao +import com.ivy.wallet.ui.csv.ImportantFields +import com.ivy.wallet.ui.csv.OptionalFields +import com.ivy.wallet.ui.csv.TransferFields +import com.ivy.wallet.ui.theme.Green +import com.ivy.wallet.ui.theme.IvyDark +import com.ivy.wallet.ui.theme.components.IVY_COLOR_PICKER_COLORS_FREE +import com.ivy.wallet.utils.toLowerCaseLocal +import java.util.* +import javax.inject.Inject +import kotlin.math.absoluteValue +import com.ivy.wallet.ui.csv.CSVRow as CSVRowNew + +class CSVImporterV2 @Inject constructor( + private val settingsDao: SettingsDao, + private val accountDao: AccountDao, + private val categoryDao: CategoryDao, + private val transactionDao: TransactionDao +) { + + lateinit var accounts: List + lateinit var categories: List + + private var newCategoryColorIndex = 0 + private var newAccountColorIndex = 0 + + suspend fun import( + csv: List, + importantFields: ImportantFields, + transferFields: TransferFields, + optionalFields: OptionalFields, + onProgress: suspend (progressPercent: Double) -> Unit, + ): ImportResult { + val rows = csv.drop(1) // drop the header + val rowsCount = rows.size + + newCategoryColorIndex = 0 + newAccountColorIndex = 0 + + accounts = accountDao.findAll().map { it.toDomain() } + val initialAccountsCount = accounts.size + + categories = categoryDao.findAll().map { it.toDomain() } + val initialCategoriesCount = categories.size + + val baseCurrency = settingsDao.findFirst().currency + + val failedRows = mutableListOf() + + + val transactions = rows.mapIndexedNotNull { index, row -> + val progressPercent = if (rowsCount > 0) + index / rowsCount.toDouble() else 0.0 + onProgress(progressPercent / 2) + + val transaction = mapToTransaction( + baseCurrency = baseCurrency, + importantFields = importantFields, + transferFields = transferFields, + optionalFields = optionalFields, + row = row, + ) + + if (transaction == null) { + failedRows.add( + CSVRow( + index = index + 2, //+ 1 because we skip Header and +1 because they don't start from zero + content = row.values + ) + ) + } + transaction + } + + + for ((index, transaction) in transactions.withIndex()) { + val progressPercent = if (rowsCount > 0) + index / transactions.size.toDouble() else 0.0 + onProgress(0.5 + progressPercent / 2) + transactionDao.save(transaction.toEntity()) + } + + return ImportResult( + rowsFound = rowsCount, + transactionsImported = transactions.size, + accountsImported = accounts.size - initialAccountsCount, + categoriesImported = categories.size - initialCategoriesCount, + failedRows = failedRows + ) + } + + private suspend fun mapToTransaction( + baseCurrency: String, + row: CSVRowNew, + importantFields: ImportantFields, + transferFields: TransferFields, + optionalFields: OptionalFields, + ): Transaction? { + val type = parseTransactionType( + value = row.extractValue(importantFields.type), + metadata = importantFields.type.metadata, + ) ?: return null + + val toAccount = if (type == TransactionType.TRANSFER) { + mapAccount( + baseCurrency = baseCurrency, + accountNameString = parseAccount( + value = row.extractValue(transferFields.toAccount), + metadata = transferFields.toAccount.metadata + ), + currencyRawString = parseAccountCurrency( + value = row.extractValue(transferFields.toAccountCurrency), + metadata = transferFields.toAccountCurrency.metadata + ), + color = null, + icon = null, + orderNum = null, + ) + } else null + + val csvAmount = if (type != TransactionType.TRANSFER) { + parseAmount( + value = row.extractValue(importantFields.amount), + metadata = importantFields.amount.metadata + ) + } else { + parseAmount( + value = row.extractValue(transferFields.toAmount), + metadata = transferFields.toAmount.metadata + ) + } ?: return null + val amount = csvAmount.absoluteValue + + if (amount <= 0) { + //Cannot save transactions with zero amount + return null + } + + val toAmount = if (type == TransactionType.TRANSFER) { + parseAmount( + value = row.extractValue(transferFields.toAmount), + metadata = transferFields.toAmount.metadata + ) + } else null + + + val dateTime = parseDate( + row.extractValue(importantFields.date), + importantFields.date.metadata + ) + val dueDate = null + if (dateTime == null) { + //Cannot save transactions without any date + return null + } + + val account = mapAccount( + baseCurrency = baseCurrency, + accountNameString = parseAccount( + value = row.extractValue(importantFields.account), + metadata = importantFields.account.metadata + ), + currencyRawString = parseAccountCurrency( + value = row.extractValue(importantFields.accountCurrency), + metadata = importantFields.accountCurrency.metadata + ), + color = null, + icon = null, + orderNum = null, + ) ?: return null + + val category = mapCategory( + categoryNameString = parseCategory( + value = row.extractValue(optionalFields.category), + metadata = optionalFields.category.metadata + ), + color = null, + icon = null, + orderNum = null, + ) + val title = parseTitle( + row.extractValue(optionalFields.title), + optionalFields.title.metadata + ) + val description = parseTitle( + row.extractValue(optionalFields.description), + optionalFields.description.metadata + ) + + return Transaction( + id = UUID.randomUUID(), + type = type, + amount = amount.toBigDecimal(), + accountId = account.id, + toAccountId = toAccount?.id, + toAmount = toAmount?.toBigDecimal() ?: amount.toBigDecimal(), + dateTime = dateTime, + dueDate = dueDate, + categoryId = category?.id, + title = title, + description = description + ) + } + + + private suspend fun mapAccount( + baseCurrency: String, + accountNameString: String?, + color: Int?, + icon: String?, + orderNum: Double?, + currencyRawString: String?, + ): Account? { + if (accountNameString == null || accountNameString.isBlank()) return null + + val existingAccount = accounts.firstOrNull { + accountNameString.toLowerCaseLocal() == it.name.toLowerCaseLocal() + } + if (existingAccount != null) { + return existingAccount + } + + //create new account + val colorArgb = color ?: when { + accountNameString.toLowerCaseLocal().contains("cash") -> { + Green + } + accountNameString.toLowerCaseLocal().contains("revolut") -> { + IvyDark + } + else -> IVY_COLOR_PICKER_COLORS_FREE.getOrElse(newAccountColorIndex++) { + newAccountColorIndex = 0 + IVY_COLOR_PICKER_COLORS_FREE.first() + } + }.toArgb() + + val newAccount = Account( + name = accountNameString, + currency = mapCurrency( + baseCurrency = baseCurrency, + currencyCode = currencyRawString + ), + color = colorArgb, + icon = icon, + orderNum = orderNum ?: accountDao.findMaxOrderNum().nextOrderNum() + ) + accountDao.save(newAccount.toEntity()) + accounts = accountDao.findAll().map { it.toDomain() } + + return newAccount + } + + private fun mapCurrency( + baseCurrency: String, + currencyCode: String? + ): String { + return try { + if (currencyCode != null && currencyCode.isNotBlank()) { + IvyCurrency.fromCode(currencyCode)?.code ?: baseCurrency + } else { + baseCurrency + } + } catch (e: Exception) { + baseCurrency + } + + } + + private suspend fun mapCategory( + categoryNameString: String?, + color: Int?, + icon: String?, + orderNum: Double? + ): Category? { + if (categoryNameString == null || categoryNameString.isBlank()) return null + + val existingCategory = categories.firstOrNull { + categoryNameString.toLowerCaseLocal() == it.name.toLowerCaseLocal() + } + if (existingCategory != null) { + return existingCategory + } + + //create new category + val colorArgb = color ?: IVY_COLOR_PICKER_COLORS_FREE.getOrElse(newCategoryColorIndex++) { + newCategoryColorIndex = 0 + IVY_COLOR_PICKER_COLORS_FREE.first() + }.toArgb() + + val newCategory = Category( + name = categoryNameString, + color = colorArgb, + icon = icon, + orderNum = orderNum ?: categoryDao.findMaxOrderNum().nextOrderNum() + ) + categoryDao.save(newCategory.toEntity()) + categories = categoryDao.findAll().map { it.toDomain() } + + return newCategory + } +} \ No newline at end of file diff --git a/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseFields.kt b/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseFields.kt index 99c1ae7914..3a9928c1f5 100644 --- a/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseFields.kt +++ b/app/src/main/java/com/ivy/wallet/ui/csv/domain/ParseFields.kt @@ -1,6 +1,8 @@ package com.ivy.wallet.ui.csv.domain import com.ivy.wallet.domain.data.TransactionType +import com.ivy.wallet.ui.csv.CSVRow +import com.ivy.wallet.ui.csv.ColumnMapping import com.ivy.wallet.ui.csv.DateMetadata import com.ivy.wallet.ui.csv.TrnTypeMetadata import java.time.LocalDateTime @@ -178,6 +180,15 @@ fun parseDescription( metadata: Unit ): String? = notBlankTrimmedString(value) +fun CSVRow.extractValue( + mapping: ColumnMapping +): String { + return try { + values[mapping.index] + } catch (e: Exception) { + "" + } +} // region Util private fun notBlankTrimmedString(value: String): String? =