From f60fbe82784fd539d35532c56ff2eff49f2ead7f Mon Sep 17 00:00:00 2001 From: Vishwa-Raghavendra <42895647+Vishwa-Raghavendra@users.noreply.github.com> Date: Mon, 4 Mar 2024 00:09:49 +0530 Subject: [PATCH] [IVY-2863] Tags UI Composables (#3005) * [IVY-2863] Tags UI Composables * [IVY-2863] Fix Unit Tests * [IVY-2863] Tags Refactor & Bug Fixes * [IVY-2863] Tagbased Filtering in Reports Screen * [IVy-2863] Tags Refactor --- .../ivy-compose-stability-baseline.txt | 3 +- .../java/com/ivy/reports/FilterOverlay.kt | 120 +++++++++++++++++- .../main/java/com/ivy/reports/ReportFilter.kt | 7 +- .../main/java/com/ivy/reports/ReportScreen.kt | 4 + .../java/com/ivy/reports/ReportScreenEvent.kt | 1 + .../java/com/ivy/reports/ReportScreenState.kt | 5 +- .../java/com/ivy/reports/ReportViewModel.kt | 60 ++++++++- .../ivy/transactions/TransactionsViewModel.kt | 8 +- .../java/com/ivy/base/legacy/LegacyTag.kt | 9 ++ .../java/com/ivy/base/legacy/Transaction.kt | 5 + .../data/db/dao/fake/FakeTransactionDao.kt | 4 + .../ivy/data/db/dao/read/TagAssociationDao.kt | 11 ++ .../java/com/ivy/data/db/dao/read/TagDao.kt | 19 ++- .../ivy/data/db/dao/read/TransactionDao.kt | 3 + .../main/java/com/ivy/data/di/RoomDbModule.kt | 6 + .../java/com/ivy/data/model/Transaction.kt | 5 + .../com/ivy/data/repository/TagsRepository.kt | 7 +- .../data/repository/TransactionRepository.kt | 1 + .../repository/impl/TagsRepositoryImpl.kt | 91 ++++++++++++- .../impl/TransactionRepositoryImpl.kt | 76 +++++++++-- .../ivy/data/repository/mapper/TagMapper.kt | 13 +- .../repository/mapper/TransactionMapper.kt | 13 +- .../impl/TransactionRepositoryImplTest.kt | 97 ++++++++++---- .../mapper/TransactionMapperTest.kt | 19 ++- .../resources/src/main/res/values/strings.xml | 2 + .../legacy/datamodel/temp/TransactionExt.kt | 13 +- .../action/transaction/TrnsWithDateDivsAct.kt | 5 +- .../pure/transaction/TrnDateDividers.kt | 17 ++- .../legacy/ui/component/tags/ShowTagModal.kt | 19 ++- .../component/transaction/TransactionCard.kt | 43 +++++++ 30 files changed, 597 insertions(+), 89 deletions(-) create mode 100644 shared/base/src/main/java/com/ivy/base/legacy/LegacyTag.kt diff --git a/ci-actions/compose-stability/ivy-compose-stability-baseline.txt b/ci-actions/compose-stability/ivy-compose-stability-baseline.txt index aa8859a3fe..9f09c44677 100644 --- a/ci-actions/compose-stability/ivy-compose-stability-baseline.txt +++ b/ci-actions/compose-stability/ivy-compose-stability-baseline.txt @@ -107,4 +107,5 @@ com.ivy.reports.PeriodFilter com.ivy.reports.AccountsFilter com.ivy.reports.CategoriesFilter com.ivy.reports.AmountFilter -com.ivy.reports.KeywordsFilter \ No newline at end of file +com.ivy.reports.KeywordsFilter +com.ivy.reports.OthersFilter \ No newline at end of file diff --git a/screen/reports/src/main/java/com/ivy/reports/FilterOverlay.kt b/screen/reports/src/main/java/com/ivy/reports/FilterOverlay.kt index 0f6f7217d3..17100df493 100644 --- a/screen/reports/src/main/java/com/ivy/reports/FilterOverlay.kt +++ b/screen/reports/src/main/java/com/ivy/reports/FilterOverlay.kt @@ -1,10 +1,12 @@ package com.ivy.reports import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +21,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -35,6 +38,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.ivy.base.model.TransactionType +import com.ivy.data.model.Tag import com.ivy.design.l0_system.UI import com.ivy.design.l0_system.style import com.ivy.domain.legacy.ui.theme.components.ListItem @@ -42,6 +46,8 @@ import com.ivy.legacy.IvyWalletPreview import com.ivy.legacy.datamodel.Account import com.ivy.legacy.datamodel.Category import com.ivy.legacy.ivyWalletCtx +import com.ivy.legacy.ui.component.tags.AddTagButton +import com.ivy.legacy.ui.component.tags.ShowTagModal import com.ivy.legacy.utils.capitalizeLocal import com.ivy.legacy.utils.springBounce import com.ivy.resources.R @@ -68,9 +74,14 @@ import com.ivy.wallet.ui.theme.modal.ChoosePeriodModalData import com.ivy.wallet.ui.theme.modal.edit.AmountModal import com.ivy.wallet.ui.theme.toComposeColor import com.ivy.wallet.ui.theme.wallet.AmountCurrencyB1Row +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import java.util.UUID import kotlin.math.roundToInt +@Suppress("LongMethod") +@OptIn(ExperimentalFoundationApi::class) @Composable fun BoxWithConstraintsScope.FilterOverlay( visible: Boolean, @@ -78,10 +89,12 @@ fun BoxWithConstraintsScope.FilterOverlay( baseCurrency: String, accounts: List, categories: List, + allTags: ImmutableList, filter: ReportFilter?, onClose: () -> Unit, - onSetFilter: (ReportFilter?) -> Unit + onSetFilter: (ReportFilter?) -> Unit, + onTagSearch: (String) -> Unit ) { val percentVisible by animateFloatAsState( targetValue = if (visible) 1f else 0f, @@ -109,6 +122,12 @@ fun BoxWithConstraintsScope.FilterOverlay( var maxAmountModalShown by remember { mutableStateOf(false) } var includeKeywordModalShown by remember { mutableStateOf(false) } var excludeKeywordModalShown by remember { mutableStateOf(false) } + var tagModalVisible by remember { mutableStateOf(false) } + val selectedTags by remember(localFilter) { + derivedStateOf { + localFilter?.selectedTags?.toImmutableList() ?: persistentListOf() + } + } if (percentVisible > 0.01f) { Column( @@ -241,6 +260,15 @@ fun BoxWithConstraintsScope.FilterOverlay( } ) + FilterDivider() + + OthersFilter( + filter = localFilter, + onTagButtonClicked = { + tagModalVisible = true + } + ) + Spacer(Modifier.height(196.dp)) } } @@ -356,6 +384,86 @@ fun BoxWithConstraintsScope.FilterOverlay( .toSet().toList() // filter duplicated ) } + + ShowTagModal( + visible = tagModalVisible, + selectOnlyMode = true, + onDismiss = { + tagModalVisible = false + // Reset TagList, avoids showing incorrect tag list if user had searched for a tag previously + onTagSearch("") + }, + allTagList = allTags, + selectedTagList = selectedTags, + onTagAdd = { + // Do Nothing + }, + onTagEdit = { oldTag, newTag -> + // Do Nothing + }, + onTagDelete = { + // Do Nothing + }, + onTagSelected = { + localFilter = nonNullFilter(localFilter).copy( + selectedTags = nonNullFilter(localFilter).selectedTags.plus(it) + ) + }, + onTagDeSelected = { + localFilter = nonNullFilter(localFilter).copy( + selectedTags = nonNullFilter(localFilter).selectedTags.minus(it) + ) + }, + onTagSearch = { + onTagSearch(it) + } + ) +} + +@Composable +fun ColumnScope.OthersFilter( + filter: ReportFilter?, + onTagButtonClicked: () -> Unit +) { + FilterTitleText( + text = stringResource(R.string.others_optional), + active = false + ) + + TagFilter( + selectedTags = filter?.selectedTags?.toImmutableList() ?: persistentListOf(), + onTagButtonClicked = onTagButtonClicked + ) +} + +@Composable +fun ColumnScope.TagFilter( + selectedTags: ImmutableList, + onTagButtonClicked: () -> Unit, + @Suppress("UnusedParameter") modifier: Modifier = Modifier +) { + Text( + modifier = Modifier.padding(start = 32.dp, top = 16.dp), + text = stringResource(R.string.tags), + style = UI.typo.b2.style( + fontWeight = FontWeight.ExtraBold + ) + ) + + Spacer(Modifier.height(12.dp)) + + if (selectedTags.isEmpty()) { + AddKeywordButton( + modifier = Modifier.padding(start = 24.dp), + text = "Select Tags" + ) { + onTagButtonClicked() + } + } else { + AddTagButton(transactionAssociatedTags = selectedTags) { + onTagButtonClicked() + } + } } @Composable @@ -825,11 +933,9 @@ private fun Keyword( } @Composable -private fun AddKeywordButton( - text: String, - onCLick: () -> Unit -) { +private fun AddKeywordButton(text: String, modifier: Modifier = Modifier, onCLick: () -> Unit) { IvyOutlinedButton( + modifier = modifier, text = text, iconStart = R.drawable.ic_plus, padding = 10.dp, @@ -903,8 +1009,10 @@ private fun Preview() { maxAmount = 13256.27, ), onClose = { }, + allTags = persistentListOf(), onSetFilter = { - } + }, + onTagSearch = { } ) } } diff --git a/screen/reports/src/main/java/com/ivy/reports/ReportFilter.kt b/screen/reports/src/main/java/com/ivy/reports/ReportFilter.kt index 9fd3aca515..12183e288e 100644 --- a/screen/reports/src/main/java/com/ivy/reports/ReportFilter.kt +++ b/screen/reports/src/main/java/com/ivy/reports/ReportFilter.kt @@ -1,6 +1,7 @@ package com.ivy.reports import com.ivy.base.model.TransactionType +import com.ivy.data.model.Tag import com.ivy.legacy.data.model.TimePeriod import com.ivy.legacy.datamodel.Account import com.ivy.legacy.datamodel.Category @@ -16,7 +17,8 @@ data class ReportFilter( val minAmount: Double?, val maxAmount: Double?, val includeKeywords: List, - val excludeKeywords: List + val excludeKeywords: List, + val selectedTags: List ) { companion object { fun emptyFilter( @@ -30,7 +32,8 @@ data class ReportFilter( includeKeywords = emptyList(), excludeKeywords = emptyList(), minAmount = null, - maxAmount = null + maxAmount = null, + selectedTags = emptyList() ) } diff --git a/screen/reports/src/main/java/com/ivy/reports/ReportScreen.kt b/screen/reports/src/main/java/com/ivy/reports/ReportScreen.kt index dce0cd2564..00e1d842d6 100644 --- a/screen/reports/src/main/java/com/ivy/reports/ReportScreen.kt +++ b/screen/reports/src/main/java/com/ivy/reports/ReportScreen.kt @@ -295,6 +295,7 @@ private fun BoxWithConstraintsScope.UI( accounts = state.accounts, categories = state.categories, filter = state.filter, + allTags = state.allTags, onClose = { onEventHandler.invoke( ReportScreenEvent.OnFilterOverlayVisible( @@ -304,6 +305,9 @@ private fun BoxWithConstraintsScope.UI( }, onSetFilter = { onEventHandler.invoke(ReportScreenEvent.OnFilter(filter = it)) + }, + onTagSearch = { + onEventHandler.invoke(ReportScreenEvent.OnTagSearch(data = it)) } ) } diff --git a/screen/reports/src/main/java/com/ivy/reports/ReportScreenEvent.kt b/screen/reports/src/main/java/com/ivy/reports/ReportScreenEvent.kt index 961469d6d9..bcf08248fa 100644 --- a/screen/reports/src/main/java/com/ivy/reports/ReportScreenEvent.kt +++ b/screen/reports/src/main/java/com/ivy/reports/ReportScreenEvent.kt @@ -12,6 +12,7 @@ sealed class ReportScreenEvent { data class OnUpcomingExpanded(val upcomingExpanded: Boolean) : ReportScreenEvent() data class OnOverdueExpanded(val overdueExpanded: Boolean) : ReportScreenEvent() data class OnFilterOverlayVisible(val filterOverlayVisible: Boolean) : ReportScreenEvent() + data class OnTagSearch(val data: String) : ReportScreenEvent() data class OnTreatTransfersAsIncomeExpense(val transfersAsIncomeExpense: Boolean) : ReportScreenEvent() diff --git a/screen/reports/src/main/java/com/ivy/reports/ReportScreenState.kt b/screen/reports/src/main/java/com/ivy/reports/ReportScreenState.kt index 38c111e88d..99d26853ca 100644 --- a/screen/reports/src/main/java/com/ivy/reports/ReportScreenState.kt +++ b/screen/reports/src/main/java/com/ivy/reports/ReportScreenState.kt @@ -2,12 +2,14 @@ package com.ivy.reports import com.ivy.data.model.Transaction import com.ivy.base.legacy.TransactionHistoryItem +import com.ivy.data.model.Tag import com.ivy.legacy.datamodel.Account import com.ivy.legacy.datamodel.Category import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.util.* +@Suppress("DataClassDefaultValues") data class ReportScreenState( val baseCurrency: String = "", val balance: Double = 0.0, @@ -30,5 +32,6 @@ data class ReportScreenState( val transactions: ImmutableList = persistentListOf(), val filterOverlayVisible: Boolean = false, val showTransfersAsIncExpCheckbox: Boolean = false, - val treatTransfersAsIncExp: Boolean = false + val treatTransfersAsIncExp: Boolean = false, + val allTags: ImmutableList = persistentListOf() ) diff --git a/screen/reports/src/main/java/com/ivy/reports/ReportViewModel.kt b/screen/reports/src/main/java/com/ivy/reports/ReportViewModel.kt index 072654e865..7ef05e0e98 100644 --- a/screen/reports/src/main/java/com/ivy/reports/ReportViewModel.kt +++ b/screen/reports/src/main/java/com/ivy/reports/ReportViewModel.kt @@ -20,6 +20,10 @@ import com.ivy.data.temp.migration.getValue import com.ivy.data.repository.TransactionRepository import com.ivy.data.repository.mapper.TransactionMapper import com.ivy.base.ComposeViewModel +import com.ivy.data.model.Tag +import com.ivy.data.model.TransactionId +import com.ivy.data.model.primitive.NotBlankTrimmedString +import com.ivy.data.repository.TagsRepository import com.ivy.domain.RootScreen import com.ivy.frp.filterSuspend import com.ivy.legacy.IvyWalletCtx @@ -49,8 +53,12 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.math.BigDecimal import java.time.ZoneId import java.util.UUID @@ -69,7 +77,8 @@ class ReportViewModel @Inject constructor( private val trnsWithDateDivsAct: TrnsWithDateDivsAct, private val calcTrnsIncomeExpenseAct: CalcTrnsIncomeExpenseAct, private val baseCurrencyAct: BaseCurrencyAct, - private val transactionMapper: TransactionMapper + private val transactionMapper: TransactionMapper, + private val tagsRepository: TagsRepository ) : ComposeViewModel() { private val unSpecifiedCategory = Category(stringRes(R.string.unspecified), color = Gray.toArgb()) @@ -97,6 +106,10 @@ class ReportViewModel @Inject constructor( private val filterOverlayVisible = mutableStateOf(false) private val showTransfersAsIncExpCheckbox = mutableStateOf(false) private val treatTransfersAsIncExp = mutableStateOf(false) + private val allTags = mutableStateOf>(persistentListOf()) + + private var tagSearchJob: Job? = null + private val tagSearchDebounceTimeInMills: Long = 500 @Composable override fun uiState(): ReportScreenState { @@ -126,7 +139,8 @@ class ReportViewModel @Inject constructor( upcomingExpanded = upcomingExpanded.value, upcomingExpenses = upcomingExpenses.doubleValue, upcomingIncome = upcomingIncome.doubleValue, - upcomingTransactions = upcomingTransactions.value + upcomingTransactions = upcomingTransactions.value, + allTags = allTags.value ) } @@ -147,6 +161,26 @@ class ReportViewModel @Inject constructor( is ReportScreenEvent.OnTreatTransfersAsIncomeExpense -> onTreatTransfersAsIncomeExpense( event.transfersAsIncomeExpense ) + is ReportScreenEvent.OnTagSearch -> onTagSearch(event.data) + } + } + } + + private suspend fun onTagSearch(query: String) { + withContext(Dispatchers.IO) { + tagSearchJob?.cancelAndJoin() + delay(tagSearchDebounceTimeInMills) // Debounce effect + tagSearchJob = launch(Dispatchers.IO) { + NotBlankTrimmedString.from(query.toLowerCaseLocal()) + .fold( + ifRight = { + allTags.value = + tagsRepository.findByText(text = it.value).toImmutableList() + }, + ifLeft = { + allTags.value = tagsRepository.findAll().toImmutableList() + } + ) } } } @@ -157,6 +191,7 @@ class ReportViewModel @Inject constructor( accounts.value = accountsAct(Unit) categories.value = (listOf(unSpecifiedCategory) + categoriesAct(Unit)).toImmutableList() + allTags.value = tagsRepository.findAll().toImmutableList() } } @@ -274,8 +309,21 @@ class ReportViewModel @Inject constructor( filter.categories.map { if (it.id == unSpecifiedCategory.id) null else it.id } val filterRange = filter.period?.toRange(ivyContext.startDayOfMonth) - return transactionRepository - .findAll() + val transactions = if (filter.selectedTags.isNotEmpty()) { + tagsRepository.findByAllAssociatedIdForTagId(filter.selectedTags.map { it.id }) + .asSequence() + .flatMap { it.value } + .map { TransactionId(it.associatedId.value) } + .distinct() + .toList() + .let { + transactionRepository.findByIds(it) + } + } else { + transactionRepository.findAll() + } + + return transactions .filter { with(transactionMapper) { filter.trnTypes.contains(it.getTransactionType()) @@ -373,10 +421,8 @@ class ReportViewModel @Inject constructor( } } } - true - } - .toImmutableList() + }.toImmutableList() } private fun String.containsLowercase(anotherString: String): Boolean { diff --git a/screen/transactions/src/main/java/com/ivy/transactions/TransactionsViewModel.kt b/screen/transactions/src/main/java/com/ivy/transactions/TransactionsViewModel.kt index a32df8a85d..cea545210a 100644 --- a/screen/transactions/src/main/java/com/ivy/transactions/TransactionsViewModel.kt +++ b/screen/transactions/src/main/java/com/ivy/transactions/TransactionsViewModel.kt @@ -21,6 +21,7 @@ import com.ivy.data.db.dao.write.WritePlannedPaymentRuleDao import com.ivy.data.db.dao.write.WriteTransactionDao import com.ivy.data.model.AccountId import com.ivy.data.repository.AccountRepository +import com.ivy.data.repository.TagsRepository import com.ivy.data.repository.mapper.TransactionMapper import com.ivy.frp.then import com.ivy.legacy.IvyWalletCtx @@ -28,6 +29,7 @@ import com.ivy.legacy.data.model.TimePeriod import com.ivy.legacy.data.model.toCloseTimeRange import com.ivy.legacy.datamodel.Category import com.ivy.legacy.datamodel.temp.toDomain +import com.ivy.legacy.datamodel.temp.toImmutableLegacyTags import com.ivy.legacy.domain.deprecated.logic.AccountCreator import com.ivy.legacy.utils.computationThread import com.ivy.legacy.utils.dateNowUTC @@ -89,6 +91,7 @@ class TransactionsViewModel @Inject constructor( private val categoryWriter: WriteCategoryDao, private val plannedPaymentRuleWriter: WritePlannedPaymentRuleDao, private val transactionMapper: TransactionMapper, + private val tagsRepository: TagsRepository ) : ComposeViewModel() { private val period = mutableStateOf(ivyContext.selectedPeriod) @@ -377,7 +380,10 @@ class TransactionsViewModel @Inject constructor( LegacyTrnsWithDateDivsAct.Input( baseCurrency = baseCurrency.value, transactions = with(transactionMapper) { - it.map { it.toEntity().toDomain() } + it.map { + val tags = tagsRepository.findByIds(it.tags).toImmutableLegacyTags() + it.toEntity().toDomain(tags = tags) + } } ) ) diff --git a/shared/base/src/main/java/com/ivy/base/legacy/LegacyTag.kt b/shared/base/src/main/java/com/ivy/base/legacy/LegacyTag.kt new file mode 100644 index 0000000000..9241856a5f --- /dev/null +++ b/shared/base/src/main/java/com/ivy/base/legacy/LegacyTag.kt @@ -0,0 +1,9 @@ +package com.ivy.base.legacy + +import androidx.compose.runtime.Immutable +import java.util.UUID + +@Immutable +@Deprecated("Use Tag Data Model") +@Suppress("DataClassTypedIDs") +data class LegacyTag(val id: UUID, val name: String) diff --git a/shared/base/src/main/java/com/ivy/base/legacy/Transaction.kt b/shared/base/src/main/java/com/ivy/base/legacy/Transaction.kt index c1aca3923c..c0b6af8c61 100644 --- a/shared/base/src/main/java/com/ivy/base/legacy/Transaction.kt +++ b/shared/base/src/main/java/com/ivy/base/legacy/Transaction.kt @@ -2,6 +2,8 @@ package com.ivy.base.legacy import androidx.compose.runtime.Immutable import com.ivy.base.model.TransactionType +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import java.math.BigDecimal import java.time.LocalDate import java.time.LocalDateTime @@ -37,5 +39,8 @@ data class Transaction( val isSynced: Boolean = false, val isDeleted: Boolean = false, + @Suppress("DataClassDefaultValues") + val tags: ImmutableList = persistentListOf(), + val id: UUID = UUID.randomUUID() ) : TransactionHistoryItem diff --git a/shared/data/src/main/java/com/ivy/data/db/dao/fake/FakeTransactionDao.kt b/shared/data/src/main/java/com/ivy/data/db/dao/fake/FakeTransactionDao.kt index 1ac7b71d7b..e6465635a9 100644 --- a/shared/data/src/main/java/com/ivy/data/db/dao/fake/FakeTransactionDao.kt +++ b/shared/data/src/main/java/com/ivy/data/db/dao/fake/FakeTransactionDao.kt @@ -230,6 +230,10 @@ class FakeTransactionDao : TransactionDao, WriteTransactionDao { return items.find { it.id == id } } + override suspend fun findByIds(ids: List): List { + return items.filter { it.id in ids } + } + override suspend fun findByIsSyncedAndIsDeleted( synced: Boolean, deleted: Boolean diff --git a/shared/data/src/main/java/com/ivy/data/db/dao/read/TagAssociationDao.kt b/shared/data/src/main/java/com/ivy/data/db/dao/read/TagAssociationDao.kt index fad3c1f86e..d2d9dbe156 100644 --- a/shared/data/src/main/java/com/ivy/data/db/dao/read/TagAssociationDao.kt +++ b/shared/data/src/main/java/com/ivy/data/db/dao/read/TagAssociationDao.kt @@ -1,6 +1,7 @@ package com.ivy.data.db.dao.read import androidx.room.Dao +import androidx.room.MapColumn import androidx.room.Query import com.ivy.data.db.entity.TagAssociationEntity import java.util.UUID @@ -15,4 +16,14 @@ interface TagAssociationDao { @Query("SELECT * FROM tags_association WHERE associatedId = :associatedId") suspend fun findByAssociatedId(associatedId: UUID): TagAssociationEntity? + + @Suppress( + "ArgumentListWrapping", + "ParameterListWrapping", + "AnnotationOnSeparateLine", + "MaximumLineLength", + "MaxLineLength" + ) + @Query("SELECT * FROM tags_association WHERE tagId in (:tagIds)") + suspend fun findByAllAssociatedIdForTagId(tagIds: List): Map<@MapColumn(columnName = "tagId") UUID, List> } \ No newline at end of file diff --git a/shared/data/src/main/java/com/ivy/data/db/dao/read/TagDao.kt b/shared/data/src/main/java/com/ivy/data/db/dao/read/TagDao.kt index 5d2a308f57..383b900f3c 100644 --- a/shared/data/src/main/java/com/ivy/data/db/dao/read/TagDao.kt +++ b/shared/data/src/main/java/com/ivy/data/db/dao/read/TagDao.kt @@ -13,18 +13,27 @@ interface TagDao { suspend fun findAll(): List @Query("SELECT * FROM tags WHERE id = :id") - suspend fun findById(id: UUID): TagEntity? + suspend fun findByIds(id: UUID): TagEntity? + + @Query("SELECT * FROM tags WHERE id in (:ids)") + suspend fun findByIds(ids: List): List @Query("SELECT * FROM tags WHERE name LIKE '%' || :text ||'%'") suspend fun findByText(text: String): List - @Suppress("AnnotationOnSeparateLine") + @Suppress( + "AnnotationOnSeparateLine", + "ArgumentListWrapping", + "MaximumLineLength", + "ParameterListWrapping", + "MaxLineLength" + ) @Query( - "SELECT tags.* FROM tags LEFT JOIN tags_association ON tags.id = tags_association.tagId " + - "WHERE associatedId IN (:ids) GROUP BY tags.id" + "SELECT tags.*,tags_association.associatedId FROM tags LEFT JOIN tags_association ON tags.id = tags_association.tagId " + + "WHERE associatedId IN (:ids)" ) @RewriteQueriesToDropUnusedColumns - suspend fun findTagsByAssociatedIds(ids: List): Map<@MapColumn(columnName = "id") UUID, List> + suspend fun findTagsByAssociatedIds(ids: List): Map<@MapColumn(columnName = "associatedId") UUID, List> @Query("SELECT tags.* FROM tags JOIN tags_association ON tags.id = tags_association.tagId WHERE associatedId = :id") @RewriteQueriesToDropUnusedColumns diff --git a/shared/data/src/main/java/com/ivy/data/db/dao/read/TransactionDao.kt b/shared/data/src/main/java/com/ivy/data/db/dao/read/TransactionDao.kt index 132989e851..faed8d4ef2 100644 --- a/shared/data/src/main/java/com/ivy/data/db/dao/read/TransactionDao.kt +++ b/shared/data/src/main/java/com/ivy/data/db/dao/read/TransactionDao.kt @@ -178,6 +178,9 @@ interface TransactionDao { @Query("SELECT * FROM transactions WHERE id = :id") suspend fun findById(id: UUID): TransactionEntity? + @Query("SELECT * FROM transactions WHERE id in (:ids)") + suspend fun findByIds(ids: List): List + @Query("SELECT * FROM transactions WHERE isSynced = :synced AND isDeleted = :deleted") suspend fun findByIsSyncedAndIsDeleted( synced: Boolean, diff --git a/shared/data/src/main/java/com/ivy/data/di/RoomDbModule.kt b/shared/data/src/main/java/com/ivy/data/di/RoomDbModule.kt index cb86cd15f0..2560475304 100644 --- a/shared/data/src/main/java/com/ivy/data/di/RoomDbModule.kt +++ b/shared/data/src/main/java/com/ivy/data/di/RoomDbModule.kt @@ -10,6 +10,7 @@ import com.ivy.data.db.dao.read.LoanDao import com.ivy.data.db.dao.read.LoanRecordDao import com.ivy.data.db.dao.read.PlannedPaymentRuleDao import com.ivy.data.db.dao.read.SettingsDao +import com.ivy.data.db.dao.read.TagAssociationDao import com.ivy.data.db.dao.read.TagDao import com.ivy.data.db.dao.read.TransactionDao import com.ivy.data.db.dao.read.UserDao @@ -95,6 +96,11 @@ object RoomDbModule { return db.tagDao } + @Provides + fun provideTagAssociationDao(db: IvyRoomDatabase): TagAssociationDao { + return db.tagAssociationDao + } + @Provides fun provideExchangeRatesDao( roomDatabase: IvyRoomDatabase diff --git a/shared/data/src/main/java/com/ivy/data/model/Transaction.kt b/shared/data/src/main/java/com/ivy/data/model/Transaction.kt index d3f41f678d..f7fe2b9c4c 100644 --- a/shared/data/src/main/java/com/ivy/data/model/Transaction.kt +++ b/shared/data/src/main/java/com/ivy/data/model/Transaction.kt @@ -2,6 +2,7 @@ package com.ivy.data.model import com.ivy.data.model.common.Value import com.ivy.data.model.primitive.NotBlankTrimmedString +import com.ivy.data.model.primitive.TagId import com.ivy.data.model.sync.Syncable import com.ivy.data.model.sync.UniqueId import java.time.Instant @@ -18,6 +19,7 @@ sealed interface Transaction : Syncable { val time: Instant val settled: Boolean val metadata: TransactionMetadata + val tags: List } data class Income( @@ -30,6 +32,7 @@ data class Income( override val metadata: TransactionMetadata, override val lastUpdated: Instant, override val removed: Boolean, + override val tags: List, val value: Value, val account: AccountId, ) : Transaction @@ -44,6 +47,7 @@ data class Expense( override val metadata: TransactionMetadata, override val lastUpdated: Instant, override val removed: Boolean, + override val tags: List, val value: Value, val account: AccountId, ) : Transaction @@ -58,6 +62,7 @@ data class Transfer( override val metadata: TransactionMetadata, override val lastUpdated: Instant, override val removed: Boolean, + override val tags: List, val fromAccount: AccountId, val fromValue: Value, val toAccount: AccountId, diff --git a/shared/data/src/main/java/com/ivy/data/repository/TagsRepository.kt b/shared/data/src/main/java/com/ivy/data/repository/TagsRepository.kt index 9695a95e15..cef2e4f18e 100644 --- a/shared/data/src/main/java/com/ivy/data/repository/TagsRepository.kt +++ b/shared/data/src/main/java/com/ivy/data/repository/TagsRepository.kt @@ -1,14 +1,19 @@ package com.ivy.data.repository import com.ivy.data.model.Tag +import com.ivy.data.model.TagAssociation import com.ivy.data.model.primitive.AssociationId import com.ivy.data.model.primitive.TagId interface TagsRepository { - suspend fun findById(id: TagId): Tag? + suspend fun findByIds(id: TagId): Tag? + suspend fun findByIds(ids: List): List suspend fun findByAssociatedId(id: AssociationId): List + suspend fun findByAssociatedId(ids: List): Map> suspend fun findAll(deleted: Boolean = false): List suspend fun findByText(text: String): List + suspend fun findByAllAssociatedIdForTagId(tagIds: List): Map> + suspend fun findByAllTagsForAssociations(): Map> suspend fun associateTagToEntity(associationId: AssociationId, tagId: TagId) suspend fun removeTagAssociation(associationId: AssociationId, tagId: TagId) suspend fun save(value: Tag) diff --git a/shared/data/src/main/java/com/ivy/data/repository/TransactionRepository.kt b/shared/data/src/main/java/com/ivy/data/repository/TransactionRepository.kt index fa88efd680..9c53b35b5c 100644 --- a/shared/data/src/main/java/com/ivy/data/repository/TransactionRepository.kt +++ b/shared/data/src/main/java/com/ivy/data/repository/TransactionRepository.kt @@ -164,6 +164,7 @@ interface TransactionRepository { ): List suspend fun findById(id: TransactionId): Transaction? + suspend fun findByIds(ids: List): List suspend fun findByIsSyncedAndIsDeleted( synced: Boolean, diff --git a/shared/data/src/main/java/com/ivy/data/repository/impl/TagsRepositoryImpl.kt b/shared/data/src/main/java/com/ivy/data/repository/impl/TagsRepositoryImpl.kt index bfebee1e12..aa7812962e 100644 --- a/shared/data/src/main/java/com/ivy/data/repository/impl/TagsRepositoryImpl.kt +++ b/shared/data/src/main/java/com/ivy/data/repository/impl/TagsRepositoryImpl.kt @@ -1,32 +1,51 @@ package com.ivy.data.repository.impl import com.ivy.base.threading.DispatchersProvider +import com.ivy.data.db.dao.read.TagAssociationDao import com.ivy.data.db.dao.read.TagDao import com.ivy.data.db.dao.write.WriteTagAssociationDao import com.ivy.data.db.dao.write.WriteTagDao import com.ivy.data.model.Tag +import com.ivy.data.model.TagAssociation import com.ivy.data.model.primitive.AssociationId import com.ivy.data.model.primitive.TagId import com.ivy.data.repository.TagsRepository import com.ivy.data.repository.mapper.TagMapper +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext +import java.util.UUID import javax.inject.Inject +import javax.inject.Singleton +@Singleton class TagsRepositoryImpl @Inject constructor( private val mapper: TagMapper, private val tagDao: TagDao, + private val tagAssociationDao: TagAssociationDao, private val writeTagDao: WriteTagDao, private val writeTagAssociationDao: WriteTagAssociationDao, private val dispatchersProvider: DispatchersProvider ) : TagsRepository { - override suspend fun findById(id: TagId): Tag? { - return withContext(dispatchersProvider.io) { - tagDao.findById(id.value)?.let { + companion object { + private const val MAX_SQL_LITE_QUERY_SIZE = 999 + } + + private val tagsMemo = mutableMapOf() + private var findAllMemoized: Boolean = false + + override suspend fun findByIds(id: TagId): Tag? { + return tagsMemo[id] ?: withContext(dispatchersProvider.io) { + tagDao.findByIds(id.value)?.let { with(mapper) { it.toDomain() } - } + }.also(::memoize) } } + override suspend fun findByIds(ids: List): List { + return ids.mapNotNull { tagsMemo[it] ?: findByIds(it) } + } + override suspend fun findByAssociatedId(id: AssociationId): List { return withContext(dispatchersProvider.io) { tagDao.findTagsByAssociatedId(id.value).let { @@ -35,10 +54,30 @@ class TagsRepositoryImpl @Inject constructor( } } + override suspend fun findByAssociatedId(ids: List): Map> { + return ids.chunked(MAX_SQL_LITE_QUERY_SIZE).map { + withContext(dispatchersProvider.io) { + async { + tagDao.findTagsByAssociatedIds(it.map { it.value }).entries.associate { (id, tags) -> + AssociationId(id) to with(mapper) { tags.map { it.toDomain() } } + } + } + } + }.awaitAll().asSequence() + .flatMap { it.asSequence() } + .associate { it.key to it.value } + } + override suspend fun findAll(deleted: Boolean): List { - return withContext(dispatchersProvider.io) { - tagDao.findAll().let { - with(mapper) { it.map { it.toDomain() } } + return if (findAllMemoized) { + tagsMemo.values.sortedByDescending { it.creationTimestamp.epochSecond } + } else { + withContext(dispatchersProvider.io) { + tagDao.findAll().let { + with(mapper) { it.map { it.toDomain() } } + } + }.also(::memoize).also { + findAllMemoized = true } } } @@ -51,6 +90,30 @@ class TagsRepositoryImpl @Inject constructor( } } + override suspend fun findByAllAssociatedIdForTagId(tagIds: List): Map> { + return withContext(dispatchersProvider.io) { + tagAssociationDao.findByAllAssociatedIdForTagId( + tagIds.toRawValues() + ).entries.associate { (id, associations) -> + with(mapper) { + TagId(id) to associations.map { it.toDomain() } + } + } + } + } + + override suspend fun findByAllTagsForAssociations(): Map> { + return withContext(dispatchersProvider.io) { + tagAssociationDao.findAll().groupBy { + AssociationId(it.associatedId) + }.mapValues { + with(mapper) { + it.value.map { it.toDomain() } + } + } + } + } + override suspend fun associateTagToEntity(associationId: AssociationId, tagId: TagId) { withContext(dispatchersProvider.io) { writeTagAssociationDao.save( @@ -70,12 +133,15 @@ class TagsRepositoryImpl @Inject constructor( override suspend fun save(value: Tag) { withContext(dispatchersProvider.io) { writeTagDao.save(with(mapper) { value.toEntity() }) + }.also { + memoize(value) } } override suspend fun updateTag(tagId: TagId, value: Tag) { withContext(dispatchersProvider.io) { writeTagDao.update(with(mapper) { value.toEntity() }) + memoize(value) } } @@ -83,12 +149,23 @@ class TagsRepositoryImpl @Inject constructor( withContext(dispatchersProvider.io) { writeTagAssociationDao.deleteAssociationsByTagId(id.value) writeTagDao.deleteById(id.value) + tagsMemo.remove(id) } } override suspend fun deleteAll() { withContext(dispatchersProvider.io) { + tagsMemo.clear() + writeTagAssociationDao.deleteAll() writeTagDao.deleteAll() } } + + private fun memoize(tag: Tag?) { + tag?.let { tagsMemo[it.id] = it } + } + private fun memoize(tags: List) { + tags.forEach(::memoize) + } + private fun List.toRawValues(): List = this.map { it.value } } diff --git a/shared/data/src/main/java/com/ivy/data/repository/impl/TransactionRepositoryImpl.kt b/shared/data/src/main/java/com/ivy/data/repository/impl/TransactionRepositoryImpl.kt index 0131d974ca..56d7a061bb 100644 --- a/shared/data/src/main/java/com/ivy/data/repository/impl/TransactionRepositoryImpl.kt +++ b/shared/data/src/main/java/com/ivy/data/repository/impl/TransactionRepositoryImpl.kt @@ -4,6 +4,7 @@ import com.ivy.base.model.TransactionType import com.ivy.base.threading.DispatchersProvider import com.ivy.data.db.dao.read.TransactionDao import com.ivy.data.db.dao.write.WriteTransactionDao +import com.ivy.data.db.entity.TransactionEntity import com.ivy.data.model.AccountId import com.ivy.data.model.CategoryId import com.ivy.data.model.Expense @@ -12,9 +13,13 @@ import com.ivy.data.model.Transaction import com.ivy.data.model.TransactionId import com.ivy.data.model.Transfer import com.ivy.data.model.primitive.AssetCode +import com.ivy.data.model.primitive.AssociationId +import com.ivy.data.model.primitive.TagId import com.ivy.data.repository.AccountRepository +import com.ivy.data.repository.TagsRepository import com.ivy.data.repository.TransactionRepository import com.ivy.data.repository.mapper.TransactionMapper +import kotlinx.coroutines.async import kotlinx.coroutines.withContext import java.time.LocalDateTime import java.util.UUID @@ -26,15 +31,25 @@ class TransactionRepositoryImpl @Inject constructor( private val transactionDao: TransactionDao, private val writeTransactionDao: WriteTransactionDao, private val dispatchersProvider: DispatchersProvider, + private val tagRepository: TagsRepository ) : TransactionRepository { override suspend fun findAll(): List { return withContext(dispatchersProvider.io) { - transactionDao.findAll().mapNotNull { + val tagMap = async { findAllTagAssociations() } + val transactions = transactionDao.findAll() + transactions.mapNotNull { val (accountAssetCode, toAccountAssetCode) = getAssetCodes( it.accountId, it.toAccountId ) - with(mapper) { it.toDomain(accountAssetCode, toAccountAssetCode) }.getOrNull() + val tags = tagMap.await()[it.id] ?: emptyList() + with(mapper) { + it.toDomain( + accountAssetCode = accountAssetCode, + toAccountAssetCode = toAccountAssetCode, + tags = tags + ) + }.getOrNull() } } } @@ -283,12 +298,20 @@ class TransactionRepositoryImpl @Inject constructor( endDate: LocalDateTime ): List { return withContext(dispatchersProvider.io) { - transactionDao.findAllBetween(startDate, endDate).mapNotNull { - val (accountAssetCode, toAccountAssetCode) = getAssetCodes( - it.accountId, - it.toAccountId - ) - with(mapper) { it.toDomain(accountAssetCode, toAccountAssetCode) }.getOrNull() + val transactions = transactionDao.findAllBetween(startDate, endDate) + val tagAssociationMap = getTagsForTransactionIds(transactions) + + transactions.mapNotNull { + val tags = tagAssociationMap[it.id] ?: emptyList() + val (accountAssetCode, toAccountAssetCode) = getAssetCodes(it.accountId, it.toAccountId) + + with(mapper) { + it.toDomain( + accountAssetCode = accountAssetCode, + toAccountAssetCode = toAccountAssetCode, + tags = tags + ) + }.getOrNull() } } } @@ -676,6 +699,26 @@ class TransactionRepositoryImpl @Inject constructor( } } + override suspend fun findByIds(ids: List): List { + return withContext(dispatchersProvider.io) { + val tagMap = async { findTagsForTransactionIds(ids) } + transactionDao.findByIds(ids.map { it.value }).mapNotNull { + val (accountAssetCode, toAccountAssetCode) = getAssetCodes( + it.accountId, + it.toAccountId + ) + val tags = tagMap.await()[it.id] ?: emptyList() + with(mapper) { + it.toDomain( + accountAssetCode = accountAssetCode, + toAccountAssetCode = toAccountAssetCode, + tags = tags + ) + }.getOrNull() + } + } + } + override suspend fun findByIsSyncedAndIsDeleted( synced: Boolean, deleted: Boolean @@ -861,4 +904,21 @@ class TransactionRepositoryImpl @Inject constructor( accountId ?: return null return accountRepository.findById(AccountId(accountId))?.asset } + + private suspend fun getTagsForTransactionIds(transactions: List): Map> { + return findTagsForTransactionIds(transactions.map { TransactionId(it.id) }) + } + + private suspend fun findTagsForTransactionIds(transactionIds: List): Map> { + return tagRepository.findByAssociatedId(transactionIds.map { AssociationId(it.value) }) + .entries.associate { + it.key.value to it.value.map { ta -> ta.id } + } + } + + private suspend fun findAllTagAssociations(): Map> { + return tagRepository.findByAllTagsForAssociations().entries.associate { + it.key.value to it.value.map { ta -> ta.id } + } + } } \ No newline at end of file diff --git a/shared/data/src/main/java/com/ivy/data/repository/mapper/TagMapper.kt b/shared/data/src/main/java/com/ivy/data/repository/mapper/TagMapper.kt index dffd3309d1..79013ccb0e 100644 --- a/shared/data/src/main/java/com/ivy/data/repository/mapper/TagMapper.kt +++ b/shared/data/src/main/java/com/ivy/data/repository/mapper/TagMapper.kt @@ -57,6 +57,15 @@ class TagMapper @Inject constructor() { ) } + fun TagAssociationEntity.toDomain(): TagAssociation { + return TagAssociation( + id = TagId(this.tagId), + associatedId = AssociationId(this.associatedId), + lastUpdated = this.lastSyncedTime, + removed = this.isDeleted + ) + } + fun createNewTag(tagId: TagId = createNewTagId(), name: NotBlankTrimmedString): Tag { return Tag( id = tagId, @@ -66,7 +75,7 @@ class TagMapper @Inject constructor() { icon = null, orderNum = 0.0, creationTimestamp = Instant.now(), - lastUpdated = Instant.now(), + lastUpdated = Instant.EPOCH, removed = false ) } @@ -75,7 +84,7 @@ class TagMapper @Inject constructor() { return TagAssociation( id = tagId, associatedId = associationId, - lastUpdated = Instant.now(), + lastUpdated = Instant.EPOCH, removed = false ) } diff --git a/shared/data/src/main/java/com/ivy/data/repository/mapper/TransactionMapper.kt b/shared/data/src/main/java/com/ivy/data/repository/mapper/TransactionMapper.kt index 9279368d03..514da2de6f 100644 --- a/shared/data/src/main/java/com/ivy/data/repository/mapper/TransactionMapper.kt +++ b/shared/data/src/main/java/com/ivy/data/repository/mapper/TransactionMapper.kt @@ -16,6 +16,7 @@ import com.ivy.data.model.common.Value import com.ivy.data.model.primitive.AssetCode import com.ivy.data.model.primitive.NotBlankTrimmedString import com.ivy.data.model.primitive.PositiveDouble +import com.ivy.data.model.primitive.TagId import java.time.Instant import java.time.ZoneId import javax.inject.Inject @@ -25,7 +26,8 @@ class TransactionMapper @Inject constructor() { @Suppress("CyclomaticComplexMethod") fun TransactionEntity.toDomain( accountAssetCode: AssetCode?, - toAccountAssetCode: AssetCode? = null + toAccountAssetCode: AssetCode? = null, + tags: List = emptyList() ): Either = either { val zoneId = ZoneId.systemDefault() val metadata = TransactionMetadata( @@ -57,7 +59,8 @@ class TransactionMapper @Inject constructor() { lastUpdated = Instant.EPOCH, removed = isDeleted, value = fromValue, - account = AccountId(accountId) + account = AccountId(accountId), + tags = tags ) } @@ -73,7 +76,8 @@ class TransactionMapper @Inject constructor() { lastUpdated = Instant.EPOCH, removed = isDeleted, value = fromValue, - account = AccountId(accountId) + account = AccountId(accountId), + tags = tags ) } @@ -104,7 +108,8 @@ class TransactionMapper @Inject constructor() { fromAccount = AccountId(accountId), fromValue = fromValue, toAccount = toAccount, - toValue = toValue + toValue = toValue, + tags = tags ) } } diff --git a/shared/data/src/test/java/com/ivy/data/repository/impl/TransactionRepositoryImplTest.kt b/shared/data/src/test/java/com/ivy/data/repository/impl/TransactionRepositoryImplTest.kt index 8d87f57f65..022f53ce84 100644 --- a/shared/data/src/test/java/com/ivy/data/repository/impl/TransactionRepositoryImplTest.kt +++ b/shared/data/src/test/java/com/ivy/data/repository/impl/TransactionRepositoryImplTest.kt @@ -21,6 +21,8 @@ import com.ivy.data.repository.AccountRepository import com.ivy.data.repository.TransactionRepository import com.ivy.data.repository.mapper.TransactionMapper import com.ivy.base.TestDispatchersProvider +import com.ivy.data.model.primitive.AssociationId +import com.ivy.data.repository.TagsRepository import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe import io.mockk.coEvery @@ -28,6 +30,7 @@ import io.mockk.coVerify import io.mockk.just import io.mockk.mockk import io.mockk.runs +import kotlinx.collections.immutable.persistentListOf import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId @@ -38,13 +41,15 @@ class TransactionRepositoryImplTest : FreeSpec({ val writeTransactionDao = mockk() val accountRepo = mockk() val mapper = TransactionMapper() + val tagsRepo = mockk() fun newRepository(): TransactionRepository = TransactionRepositoryImpl( accountRepository = accountRepo, mapper = mapper, transactionDao = transactionDao, writeTransactionDao = writeTransactionDao, - dispatchersProvider = TestDispatchersProvider + dispatchersProvider = TestDispatchersProvider, + tagRepository = tagsRepo ) fun toInstant(localDateTime: LocalDateTime): Instant { @@ -56,6 +61,7 @@ class TransactionRepositoryImplTest : FreeSpec({ // given val repository = newRepository() coEvery { transactionDao.findAll() } returns emptyList() + coEvery { tagsRepo.findByAllTagsForAssociations() } returns emptyMap() // when val res = repository.findAll() @@ -177,6 +183,7 @@ class TransactionRepositoryImplTest : FreeSpec({ ) coEvery { accountRepo.findById(account.id) } returns account coEvery { accountRepo.findById(toAccount.id) } returns toAccount + coEvery { tagsRepo.findByAllTagsForAssociations() } returns emptyMap() // when val res = repository.findAll() @@ -194,7 +201,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = AccountId(accountId) + account = AccountId(accountId), + tags = persistentListOf() ), Expense( id = TransactionId(validExpenseId), @@ -207,7 +215,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = AccountId(accountId) + account = AccountId(accountId), + tags = persistentListOf() ), Transfer( id = TransactionId(validTransferId), @@ -222,7 +231,8 @@ class TransactionRepositoryImplTest : FreeSpec({ fromAccount = AccountId(accountId), fromValue = Value(PositiveDouble(100.0), account.asset), toAccount = AccountId(toAccountId), - toValue = Value(PositiveDouble(100.0), toAccount.asset) + toValue = Value(PositiveDouble(100.0), toAccount.asset), + tags = persistentListOf() ) ) } @@ -290,7 +300,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -303,6 +314,7 @@ class TransactionRepositoryImplTest : FreeSpec({ val startDate = LocalDateTime.now().minusDays(7) val endDate = LocalDateTime.now() coEvery { transactionDao.findAllBetween(startDate, endDate) } returns emptyList() + coEvery { tagsRepo.findByAssociatedId(listOf()) } returns emptyMap() // when val res = repository.findAllBetween(startDate, endDate) @@ -373,6 +385,15 @@ class TransactionRepositoryImplTest : FreeSpec({ validIncome2, invalidIncome ) + coEvery { + tagsRepo.findByAssociatedId( + listOf( + AssociationId(validIncome.id), + AssociationId(validIncome2.id), + AssociationId(invalidIncome.id) + ) + ) + } returns emptyMap() coEvery { accountRepo.findById(account.id) } returns account // when @@ -391,7 +412,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ), Income( id = TransactionId(validIncome2Id), @@ -404,7 +426,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -520,7 +543,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -640,7 +664,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -750,7 +775,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -882,7 +908,8 @@ class TransactionRepositoryImplTest : FreeSpec({ fromAccount = AccountId(accountId), fromValue = Value(PositiveDouble(100.0), account.asset), toAccount = AccountId(toAccountId), - toValue = Value(PositiveDouble(100.0), account.asset) + toValue = Value(PositiveDouble(100.0), account.asset), + tags = persistentListOf() ) ) } @@ -1014,7 +1041,8 @@ class TransactionRepositoryImplTest : FreeSpec({ fromAccount = AccountId(accountId), fromValue = Value(PositiveDouble(100.0), account.asset), toAccount = AccountId(toAccountId), - toValue = Value(PositiveDouble(100.0), account.asset) + toValue = Value(PositiveDouble(100.0), account.asset), + tags = persistentListOf() ) ) } @@ -1131,7 +1159,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -1244,7 +1273,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -1360,7 +1390,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -1469,7 +1500,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -1589,7 +1621,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -1660,7 +1693,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) } @@ -1815,7 +1849,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = isDeleted, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -1916,7 +1951,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -2017,7 +2053,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -2090,7 +2127,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) } @@ -2206,7 +2244,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) } @@ -2354,7 +2393,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), account.asset), - account = account.id + account = account.id, + tags = persistentListOf() ) ) } @@ -2382,7 +2422,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), AssetCode("NGN")), - account = AccountId(accountId) + account = AccountId(accountId), + tags = persistentListOf() ) ) @@ -2428,7 +2469,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), AssetCode("NGN")), - account = AccountId(accountId) + account = AccountId(accountId), + tags = persistentListOf() ), Expense( id = TransactionId(transaction2Id), @@ -2441,7 +2483,8 @@ class TransactionRepositoryImplTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(PositiveDouble(100.0), AssetCode("NGN")), - account = AccountId(accountId) + account = AccountId(accountId), + tags = persistentListOf() ), ) ) diff --git a/shared/data/src/test/java/com/ivy/data/repository/mapper/TransactionMapperTest.kt b/shared/data/src/test/java/com/ivy/data/repository/mapper/TransactionMapperTest.kt index 60acf657a7..a32ad6c5b7 100644 --- a/shared/data/src/test/java/com/ivy/data/repository/mapper/TransactionMapperTest.kt +++ b/shared/data/src/test/java/com/ivy/data/repository/mapper/TransactionMapperTest.kt @@ -17,6 +17,7 @@ import io.kotest.assertions.arrow.core.shouldBeLeft import io.kotest.assertions.arrow.core.shouldBeRight import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.shouldBe +import kotlinx.collections.immutable.persistentListOf import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId @@ -50,7 +51,8 @@ class TransactionMapperTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(amount = PositiveDouble(100.0), asset = AssetCode("NGN")), - account = accountId + account = accountId, + tags = persistentListOf() ) // when @@ -103,7 +105,8 @@ class TransactionMapperTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(amount = PositiveDouble(100.0), asset = AssetCode("NGN")), - account = accountId + account = accountId, + tags = persistentListOf() ) // when @@ -159,7 +162,8 @@ class TransactionMapperTest : FreeSpec({ fromValue = Value(amount = PositiveDouble(100.0), asset = AssetCode("NGN")), fromAccount = accountId, toValue = Value(amount = PositiveDouble(100.0), asset = AssetCode("NGN")), - toAccount = toAccountId + toAccount = toAccountId, + tags = persistentListOf() ) // when @@ -239,7 +243,8 @@ class TransactionMapperTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(amount = PositiveDouble(100.0), asset = assetCode), - account = AccountId(accountId) + account = AccountId(accountId), + tags = persistentListOf() ) } @@ -353,7 +358,8 @@ class TransactionMapperTest : FreeSpec({ lastUpdated = Instant.EPOCH, removed = false, value = Value(amount = PositiveDouble(100.0), asset = assetCode), - account = AccountId(accountId) + account = AccountId(accountId), + tags = persistentListOf() ) } @@ -470,7 +476,8 @@ class TransactionMapperTest : FreeSpec({ fromValue = Value(amount = PositiveDouble(100.0), asset = assetCode), fromAccount = AccountId(accountId), toValue = Value(amount = PositiveDouble(100.0), asset = assetCode), - toAccount = AccountId(toAccountId) + toAccount = AccountId(toAccountId), + tags = persistentListOf() ) } diff --git a/shared/resources/src/main/res/values/strings.xml b/shared/resources/src/main/res/values/strings.xml index b835efcc23..f9b391a639 100644 --- a/shared/resources/src/main/res/values/strings.xml +++ b/shared/resources/src/main/res/values/strings.xml @@ -448,4 +448,6 @@ Increase Decrease Please select an account to transfer to + Others (Optional) + Tags diff --git a/temp/legacy-code/src/main/java/com/ivy/legacy/datamodel/temp/TransactionExt.kt b/temp/legacy-code/src/main/java/com/ivy/legacy/datamodel/temp/TransactionExt.kt index 3f0ca1f41e..c7a5439020 100644 --- a/temp/legacy-code/src/main/java/com/ivy/legacy/datamodel/temp/TransactionExt.kt +++ b/temp/legacy-code/src/main/java/com/ivy/legacy/datamodel/temp/TransactionExt.kt @@ -1,9 +1,14 @@ package com.ivy.legacy.datamodel.temp +import com.ivy.base.legacy.LegacyTag import com.ivy.base.legacy.Transaction import com.ivy.data.db.entity.TransactionEntity +import com.ivy.data.model.Tag +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList -fun TransactionEntity.toDomain(): Transaction = Transaction( +fun TransactionEntity.toDomain(tags: ImmutableList = persistentListOf()): Transaction = Transaction( accountId = accountId, type = type, amount = amount.toBigDecimal(), @@ -18,8 +23,12 @@ fun TransactionEntity.toDomain(): Transaction = Transaction( attachmentUrl = attachmentUrl, loanId = loanId, loanRecordId = loanRecordId, - id = id + id = id, + tags = tags ) +fun Tag.toLegacyTag(): LegacyTag = LegacyTag(this.id.value, this.name.value) +fun List.toImmutableLegacyTags(): ImmutableList = + this.map { it.toLegacyTag() }.toImmutableList() fun TransactionEntity.isIdenticalWith(transaction: TransactionEntity?): Boolean { if (transaction == null) return false diff --git a/temp/legacy-code/src/main/java/com/ivy/legacy/domain/action/transaction/TrnsWithDateDivsAct.kt b/temp/legacy-code/src/main/java/com/ivy/legacy/domain/action/transaction/TrnsWithDateDivsAct.kt index f66d143436..09600617e0 100644 --- a/temp/legacy-code/src/main/java/com/ivy/legacy/domain/action/transaction/TrnsWithDateDivsAct.kt +++ b/temp/legacy-code/src/main/java/com/ivy/legacy/domain/action/transaction/TrnsWithDateDivsAct.kt @@ -3,6 +3,7 @@ package com.ivy.wallet.domain.action.transaction import com.ivy.base.legacy.Transaction import com.ivy.base.legacy.TransactionHistoryItem import com.ivy.data.db.dao.read.AccountDao +import com.ivy.data.repository.TagsRepository import com.ivy.frp.action.FPAction import com.ivy.frp.then import com.ivy.legacy.datamodel.temp.toDomain @@ -14,13 +15,15 @@ import javax.inject.Inject class TrnsWithDateDivsAct @Inject constructor( private val accountDao: AccountDao, - private val exchangeAct: ExchangeAct + private val exchangeAct: ExchangeAct, + private val tagsRepository: TagsRepository, ) : FPAction>() { override suspend fun Input.compose(): suspend () -> List = suspend { transactionsWithDateDividers( transactions = transactions, baseCurrencyCode = baseCurrency, + getTags = { tagIds -> tagsRepository.findByIds(tagIds) }, getAccount = accountDao::findById then { it?.toDomain() }, exchange = ::actInput then exchangeAct diff --git a/temp/legacy-code/src/main/java/com/ivy/legacy/domain/pure/transaction/TrnDateDividers.kt b/temp/legacy-code/src/main/java/com/ivy/legacy/domain/pure/transaction/TrnDateDividers.kt index a69b72c7b9..d46e269edf 100644 --- a/temp/legacy-code/src/main/java/com/ivy/legacy/domain/pure/transaction/TrnDateDividers.kt +++ b/temp/legacy-code/src/main/java/com/ivy/legacy/domain/pure/transaction/TrnDateDividers.kt @@ -6,13 +6,17 @@ import com.ivy.base.legacy.TransactionHistoryItem import com.ivy.base.time.convertToLocal import com.ivy.data.db.dao.read.AccountDao import com.ivy.data.db.dao.read.SettingsDao +import com.ivy.data.model.Tag import com.ivy.data.model.Transaction +import com.ivy.data.model.primitive.TagId +import com.ivy.data.repository.TagsRepository import com.ivy.data.repository.mapper.TransactionMapper import com.ivy.frp.Pure import com.ivy.frp.SideEffect import com.ivy.frp.then import com.ivy.legacy.datamodel.Account import com.ivy.legacy.datamodel.temp.toDomain +import com.ivy.legacy.datamodel.temp.toImmutableLegacyTags import com.ivy.legacy.utils.convertUTCtoLocal import com.ivy.legacy.utils.toEpochSeconds import com.ivy.wallet.domain.data.TransactionHistoryDateDivider @@ -32,12 +36,14 @@ import java.util.UUID suspend fun List.withDateDividers( exchangeRatesLogic: ExchangeRatesLogic, settingsDao: SettingsDao, - accountDao: AccountDao + accountDao: AccountDao, + tagsRepository: TagsRepository ): List { return transactionsWithDateDividers( transactions = this, baseCurrencyCode = settingsDao.findFirst().currency, getAccount = accountDao::findById then { it?.toDomain() }, + getTags = { tagsIds -> tagsRepository.findByIds(tagsIds) }, exchange = { data, amount -> exchangeRatesLogic.convertAmount( baseCurrency = data.baseCurrency, @@ -57,7 +63,9 @@ suspend fun transactionsWithDateDividers( @SideEffect getAccount: suspend (accountId: UUID) -> Account?, @SideEffect - exchange: suspend (ExchangeData, BigDecimal) -> Option + exchange: suspend (ExchangeData, BigDecimal) -> Option, + @SideEffect + getTags: suspend (tagIds: List) -> List = { emptyList() }, ): List { if (transactions.isEmpty()) return emptyList() val transactionsMapper = TransactionMapper() @@ -77,7 +85,10 @@ suspend fun transactionsWithDateDividers( // Required to be interoperable with [TransactionHistoryItem] val legacyTransactionsForDate = with(transactionsMapper) { - transactionsForDate.map { it.toEntity().toDomain() } + transactionsForDate.map { + it.toEntity() + .toDomain(tags = getTags(it.tags).toImmutableLegacyTags()) + } } listOf( TransactionHistoryDateDivider( diff --git a/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/tags/ShowTagModal.kt b/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/tags/ShowTagModal.kt index f17bd3cfa7..9a258db783 100644 --- a/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/tags/ShowTagModal.kt +++ b/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/tags/ShowTagModal.kt @@ -72,6 +72,7 @@ fun BoxWithConstraintsScope.ShowTagModal( @Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier, id: UUID = UUID.randomUUID(), visible: Boolean = false, + selectOnlyMode: Boolean = false, onTagSearch: (String) -> Unit ) { var showTagAddModal by remember { @@ -127,6 +128,7 @@ fun BoxWithConstraintsScope.ShowTagModal( TagList( transactionTags = allTagList, selectedTagList = selectedTagList, + selectOnlyMode = selectOnlyMode, onAddNewTag = { showTagAddModal = true }, @@ -137,8 +139,10 @@ fun BoxWithConstraintsScope.ShowTagModal( onTagDeSelected(it) }, onTagLongClick = { - selectedTag = it - showTagAddModal = true + if (!selectOnlyMode) { + selectedTag = it + showTagAddModal = true + } } ) } @@ -170,7 +174,7 @@ fun BoxWithConstraintsScope.ShowTagModal( DeleteModal( visible = deleteTagModalVisible, title = stringResource(R.string.confirm_deletion), - description = "Are you sure you want to delete the following tag:\t'${selectedTag?.name}' ?", + description = "Are you sure you want to delete the following tag:\t'${selectedTag?.name?.value}' ?", dismiss = { deleteTagModalVisible = false } ) { if (selectedTag != null) { @@ -188,12 +192,17 @@ private fun ColumnScope.TagList( transactionTags: ImmutableList, onAddNewTag: () -> Unit, selectedTagList: ImmutableList, + selectOnlyMode: Boolean, onTagSelected: (Tag) -> Unit = {}, onTagDeSelected: (Tag) -> Unit = {}, onTagLongClick: (Tag) -> Unit = {} ) { - val tagListWithAddNewTag by remember(transactionTags) { - mutableStateOf(listOf(AddNewTag()) + transactionTags) + val tagListWithAddNewTag: List by remember(transactionTags) { + if (selectOnlyMode) { + mutableStateOf(transactionTags) + } else { + mutableStateOf(listOf(AddNewTag()) + transactionTags) + } } WrapContentRow( diff --git a/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/transaction/TransactionCard.kt b/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/transaction/TransactionCard.kt index 8462983beb..abb9d44b5c 100644 --- a/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/transaction/TransactionCard.kt +++ b/temp/legacy-code/src/main/java/com/ivy/legacy/ui/component/transaction/TransactionCard.kt @@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -12,6 +13,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,8 +29,10 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.ivy.base.legacy.LegacyTag import com.ivy.base.legacy.Transaction import com.ivy.base.model.TransactionType +import com.ivy.design.l0_system.BlueLight import com.ivy.design.l0_system.UI import com.ivy.design.l0_system.style import com.ivy.design.l1_buildingBlocks.IvyText @@ -66,6 +71,7 @@ import com.ivy.wallet.ui.theme.findContrastTextColor import com.ivy.wallet.ui.theme.gradientExpenses import com.ivy.wallet.ui.theme.toComposeColor import com.ivy.wallet.ui.theme.wallet.AmountCurrencyB1 +import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import java.time.LocalDateTime @@ -233,10 +239,47 @@ fun TransactionCard( } } + if (transaction.tags.isNotEmpty()) { + TransactionTags(transaction.tags) + } + Spacer(Modifier.height(20.dp)) } } +@Composable +private fun ColumnScope.TransactionTags(tags: ImmutableList) { + Spacer(Modifier.height(12.dp)) + + LazyRow( + modifier = Modifier.padding(horizontal = 24.dp) + ) { + item { + // Tag Text + Text( + text = "Tags:", + style = UI.typo.nC.style( + color = UI.colors.gray, + fontWeight = FontWeight.Normal + ) + ) + + Spacer(modifier = Modifier.width(8.dp)) + } + + items(tags, key = { it.id }) { tag -> + Text( + text = "#${tag.name}", + style = UI.typo.nC.style( + color = BlueLight, + fontWeight = FontWeight.Normal + ) + ) + Spacer(modifier = Modifier.width(6.dp)) + } + } +} + @Composable private fun TransactionHeaderRow( transaction: Transaction,