From 6d065e0af0864d1e9c5d98b18604226968afea0a Mon Sep 17 00:00:00 2001 From: Michael Totschnig Date: Sat, 28 Sep 2024 09:37:54 +0200 Subject: [PATCH] We render the no category assigned icon via Google Icons in order to bypass loading of FontAwesome Icons Fixes #1565 emToDp failed on Tools Preview --- .../myexpenses/compose/TransactionRenderer.kt | 71 ++++++++++------- .../totschnig/myexpenses/compose/tidBits.kt | 5 +- .../dialog/ArchiveDialogFragment.kt | 2 +- .../dialog/TransactionDetailFragment.kt | 4 +- .../totschnig/myexpenses/provider/Archive.kt | 48 ++++++++++-- .../myexpenses/provider/CursorExt.kt | 10 +++ .../myexpenses/BaseTestWithRepository.kt | 11 ++- .../myexpenses/provider/ArchiveTest.kt | 78 +++++++++++++++++-- 8 files changed, 180 insertions(+), 49 deletions(-) diff --git a/myExpenses/src/main/java/org/totschnig/myexpenses/compose/TransactionRenderer.kt b/myExpenses/src/main/java/org/totschnig/myexpenses/compose/TransactionRenderer.kt index 8a13d31279..6d2bb3a2de 100644 --- a/myExpenses/src/main/java/org/totschnig/myexpenses/compose/TransactionRenderer.kt +++ b/myExpenses/src/main/java/org/totschnig/myexpenses/compose/TransactionRenderer.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.filled.Archive +import androidx.compose.material.icons.filled.Remove import androidx.compose.material3.Icon import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme @@ -201,8 +202,6 @@ abstract class ItemRenderer( ) { val showMenu = remember { mutableStateOf(false) } val activatedBackgroundColor = colorResource(id = R.color.activatedBackground) - val voidMarkerHeight = with(LocalDensity.current) { 2.dp.toPx() } - val voidStatus = stringResource(id = R.string.status_void) Row(modifier = modifier .height() .conditional(selectionHandler?.isSelectable(transaction) == true, @@ -210,7 +209,7 @@ abstract class ItemRenderer( combinedClickable( onLongClick = { selectionHandler!!.toggle(transaction) }, onClick = { - if ( selectionHandler!!.selectionCount == 0) { + if (selectionHandler!!.selectionCount == 0) { showMenu.value = true } else { selectionHandler.toggle(transaction) @@ -225,18 +224,7 @@ abstract class ItemRenderer( .conditional(selectionHandler?.isSelected(transaction) == true) { background(activatedBackgroundColor) } - .conditional(transaction.crStatus == CrStatus.VOID) { - drawWithContent { - drawContent() - drawLine( - Color.Red, - Offset(0F, size.height / 2), - Offset(size.width, size.height / 2), - voidMarkerHeight - ) - } - .semantics { contentDescription = voidStatus } - } + .voidMarker(transaction.crStatus) .padding(horizontal = mainScreenPadding, vertical = 3.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -295,7 +283,11 @@ abstract class ItemRenderer( modifier = Modifier.fillMaxSize() ) - else -> Icon("minus") + else -> Icon( + imageVector = Icons.Filled.Remove, + contentDescription = stringResource(id = R.string.action_archive), + modifier = Modifier.fillMaxSize() + ) } } } @@ -480,6 +472,35 @@ fun Modifier.tagBorder(color: Color) = ) .padding(vertical = 4.dp, horizontal = 6.dp) +@Composable +fun Modifier.voidMarker(crStatus: CrStatus): Modifier { + val voidMarkerHeight = with(LocalDensity.current) { 2.dp.toPx() } + val voidStatus = stringResource(id = R.string.status_void) + return conditional(crStatus == CrStatus.VOID) { + drawWithContent { + drawContent() + drawLine( + Color.Red, + Offset(0F, size.height / 2), + Offset(size.width, size.height / 2), + voidMarkerHeight + ) + } + .semantics { contentDescription = voidStatus } + } +} + +@Composable +fun InlineChip(text: String, color: Color?) { + Text( + text = text, + modifier = Modifier + .tagBorder(color ?: MaterialTheme.colorScheme.onSurface) + .padding(bottom = 2.dp), + style = MaterialTheme.typography.bodySmall + ) +} + @Preview @Composable private fun RenderNew(@PreviewParameter(SampleProvider::class) transaction: Transaction2) { @@ -496,37 +517,27 @@ private fun RenderCompact(@PreviewParameter(SampleProvider::class) transaction: ).Render(transaction) } -@Composable -fun InlineChip(text: String, color: Color?) { - Text( - text = text, - modifier = Modifier - .tagBorder(color ?: MaterialTheme.colorScheme.onSurface) - .padding(bottom = 2.dp), - style = MaterialTheme.typography.bodySmall - ) -} - class SampleProvider : PreviewParameterProvider { private val originalCurrency = CurrencyUnit("TRY", "₺", 2) override val values = sequenceOf( Transaction2( id = -1, _date = System.currentTimeMillis() / 1000, - amount = Money(CurrencyUnit.DebugInstance, 7000), + amount = Money(CurrencyUnit.DebugInstance, 8000), originalAmount = Money(originalCurrency, 1234500), methodLabel = "CHEQUE", - methodIcon = "credit-card", + //methodIcon = "credit-card", referenceNumber = "1", accountId = -1, catId = 1, categoryPath = "Obst und Gemüse", comment = "Erika Musterfrau", - icon = "apple", + //icon = "apple", year = 2022, month = 1, day = 1, week = 1, + crStatus = CrStatus.VOID, tagList = listOf( Triple(1, "Hund", android.graphics.Color.RED), Triple(2,"Katz", android.graphics.Color.GREEN) diff --git a/myExpenses/src/main/java/org/totschnig/myexpenses/compose/tidBits.kt b/myExpenses/src/main/java/org/totschnig/myexpenses/compose/tidBits.kt index 787b308ff7..a85e196245 100644 --- a/myExpenses/src/main/java/org/totschnig/myexpenses/compose/tidBits.kt +++ b/myExpenses/src/main/java/org/totschnig/myexpenses/compose/tidBits.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp import app.futured.donut.compose.DonutProgress import app.futured.donut.compose.data.DonutModel import app.futured.donut.compose.data.DonutSection @@ -130,4 +131,6 @@ fun TypeConfiguration( } @Composable -fun emToDp(em: Float): Dp = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() } * em +fun emToDp(em: Float): Dp = with(LocalDensity.current) { + (LocalTextStyle.current.fontSize.takeIf { it.isSp } ?: 12.sp).toDp() +} * em diff --git a/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/ArchiveDialogFragment.kt b/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/ArchiveDialogFragment.kt index 9432327cd4..4c44d97292 100644 --- a/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/ArchiveDialogFragment.kt +++ b/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/ArchiveDialogFragment.kt @@ -50,7 +50,7 @@ data class ArchiveInfo( val statuses: List ) : Parcelable { val canArchive: Boolean - get() = count > 0 && !hasNested && statuses.size == 1 + get() = count > 0 && !hasNested && statuses.filter { it != CrStatus.VOID }.size <= 1 } @OptIn(ExperimentalMaterial3Api::class) diff --git a/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/TransactionDetailFragment.kt b/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/TransactionDetailFragment.kt index 951fb262f0..14493fa051 100644 --- a/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/TransactionDetailFragment.kt +++ b/myExpenses/src/main/java/org/totschnig/myexpenses/dialog/TransactionDetailFragment.kt @@ -85,6 +85,7 @@ import org.totschnig.myexpenses.compose.LocalDateFormatter import org.totschnig.myexpenses.compose.conditional import org.totschnig.myexpenses.compose.emToDp import org.totschnig.myexpenses.compose.size +import org.totschnig.myexpenses.compose.voidMarker import org.totschnig.myexpenses.db2.FinTsAttribute import org.totschnig.myexpenses.feature.BankingFeature import org.totschnig.myexpenses.injector @@ -228,6 +229,7 @@ class TransactionDetailFragment : ComposeBaseDialogFragment3() { ) { expanded -> if (expanded) { OutlinedCard(modifier = Modifier + .voidMarker(part.crStatus) .clickable { selectedArchivedTransaction = 0 } @@ -529,7 +531,7 @@ class TransactionDetailFragment : ComposeBaseDialogFragment3() { withDate: Boolean = false ) { Row( - modifier = modifier, + modifier = modifier.voidMarker(part.crStatus), verticalAlignment = Alignment.CenterVertically ) { if (withDate) { diff --git a/myExpenses/src/main/java/org/totschnig/myexpenses/provider/Archive.kt b/myExpenses/src/main/java/org/totschnig/myexpenses/provider/Archive.kt index b38a40a0e9..f457e8ad0d 100644 --- a/myExpenses/src/main/java/org/totschnig/myexpenses/provider/Archive.kt +++ b/myExpenses/src/main/java/org/totschnig/myexpenses/provider/Archive.kt @@ -75,7 +75,8 @@ fun SupportSQLiteDatabase.unarchive( } } -private const val ARCHIVE_SELECTION = "$KEY_ACCOUNTID = ? AND $KEY_PARENTID is null AND $KEY_STATUS != $STATUS_UNCOMMITTED AND $KEY_DATE > ? AND $KEY_DATE < ?" +private const val ARCHIVE_SELECTION = + "$KEY_ACCOUNTID = ? AND $KEY_PARENTID is null AND $KEY_STATUS != $STATUS_UNCOMMITTED AND $KEY_DATE > ? AND $KEY_DATE < ?" private fun SupportSQLiteDatabase.archiveInfo( accountId: Long, @@ -111,14 +112,45 @@ private fun Bundle.parseArchiveArguments() = Triple( fun SupportSQLiteDatabase.archive(extras: Bundle): Long { val (accountId, start, end) = extras.parseArchiveArguments() - val (crStatus, archiveSum, archiveDate) = archiveInfo(accountId, start, end, false).use { - if (it.count > 1) throw IllegalStateException("Transactions in archive have different states.") - it.moveToFirst() - if (it.hasNested()) throw IllegalStateException("Nested archive is not supported.") - Triple(it.getString(0), it.getLong(2), it.getLong(3)) + val (crStatus, archiveSum, archiveDate) = archiveInfo( + accountId, + start, + end, + false + ).use { cursor -> + when (cursor.count) { + 0 -> throw IllegalStateException("No transactions to archive.") + 1 -> { + cursor.moveToFirst() + if (cursor.hasNested()) throw IllegalStateException("Nested archive is not supported.") + Triple(cursor.getString(0), cursor.getLong(2), cursor.getLong(3)) + } + + 2 -> { + val states = cursor.useAndMapToMap { + it.getString(0) to Triple( + it.hasNested(), + it.getLong(2), + it.getLong(3) + ) + } + if (states.any { it.value.first }) { + throw IllegalStateException("Nested archive is not supported.") + } + if (!states.containsKey(CrStatus.VOID.name)) { + throw IllegalStateException("Transactions in archive have different states.") + } + val archive = states.entries.first { it.key != CrStatus.VOID.name } + Triple(archive.key, archive.value.second, archive.value.third) + } + + else -> { + throw IllegalStateException("Transactions in archive have different states.") + } + } } - return safeUpdateWithSealed { + return safeUpdateWithSealed { val archiveId = insert(TABLE_TRANSACTIONS, ContentValues().apply { put(KEY_ACCOUNTID, accountId) put(KEY_DATE, archiveDate) @@ -144,7 +176,7 @@ fun SupportSQLiteDatabase.archive(extras: Bundle): Long { archiveId ) ) - archiveId + archiveId } } diff --git a/myExpenses/src/main/java/org/totschnig/myexpenses/provider/CursorExt.kt b/myExpenses/src/main/java/org/totschnig/myexpenses/provider/CursorExt.kt index 9a731fdfe0..45fa026f96 100644 --- a/myExpenses/src/main/java/org/totschnig/myexpenses/provider/CursorExt.kt +++ b/myExpenses/src/main/java/org/totschnig/myexpenses/provider/CursorExt.kt @@ -18,6 +18,16 @@ fun Cursor.useAndMapToSet(mapper: (Cursor) -> T) = it.asSequence.map(mapper).toSet() } +fun Cursor.useAndMapToMap(mapper: (Cursor) -> Pair) = + use { + buildMap { + it.asSequence.forEach { + val (key, value) = mapper(it) + put(key, value) + } + } + } + /** * requires the Cursor to be positioned BEFORE first row */ diff --git a/myExpenses/src/test/java/org/totschnig/myexpenses/BaseTestWithRepository.kt b/myExpenses/src/test/java/org/totschnig/myexpenses/BaseTestWithRepository.kt index 19ea139238..4db1117b30 100644 --- a/myExpenses/src/test/java/org/totschnig/myexpenses/BaseTestWithRepository.kt +++ b/myExpenses/src/test/java/org/totschnig/myexpenses/BaseTestWithRepository.kt @@ -10,6 +10,7 @@ import org.mockito.Mockito.mock import org.mockito.Mockito.`when` import org.totschnig.myexpenses.db2.Repository import org.totschnig.myexpenses.db2.saveCategory +import org.totschnig.myexpenses.model.CrStatus import org.totschnig.myexpenses.model.CurrencyContext import org.totschnig.myexpenses.model.CurrencyUnit import org.totschnig.myexpenses.model.Grouping @@ -44,11 +45,17 @@ abstract class BaseTestWithRepository { fun writeCategory(label: String, parentId: Long? = null) = repository.saveCategory(Category(label = label, parentId = parentId))!! - protected fun insertTransaction(accountId: Long, amount: Long, categoryId: Long? = null): Pair { + protected fun insertTransaction( + accountId: Long, + amount: Long, + categoryId: Long? = null, + crStatus: CrStatus = CrStatus.UNRECONCILED + ): Pair { val contentValues = TransactionInfo( accountId = accountId, amount = amount, - catId = categoryId + catId = categoryId, + crStatus = crStatus ).contentValues val id = ContentUris.parseId(contentResolver.insert(TransactionProvider.TRANSACTIONS_URI, contentValues)!!) return id to contentValues.getAsString(DatabaseConstants.KEY_UUID) diff --git a/myExpenses/src/test/java/org/totschnig/myexpenses/provider/ArchiveTest.kt b/myExpenses/src/test/java/org/totschnig/myexpenses/provider/ArchiveTest.kt index 7876ad1f1c..75537cf6a7 100644 --- a/myExpenses/src/test/java/org/totschnig/myexpenses/provider/ArchiveTest.kt +++ b/myExpenses/src/test/java/org/totschnig/myexpenses/provider/ArchiveTest.kt @@ -9,7 +9,9 @@ import org.totschnig.myexpenses.BaseTestWithRepository import org.totschnig.myexpenses.db2.archive import org.totschnig.myexpenses.db2.unarchive import org.totschnig.myexpenses.model.AccountType +import org.totschnig.myexpenses.model.CrStatus import org.totschnig.myexpenses.provider.DatabaseConstants.KEY_AMOUNT +import org.totschnig.myexpenses.provider.DatabaseConstants.KEY_CR_STATUS import org.totschnig.myexpenses.provider.DatabaseConstants.KEY_PARENTID import org.totschnig.myexpenses.provider.DatabaseConstants.KEY_ROWID import org.totschnig.myexpenses.provider.DatabaseConstants.KEY_STATUS @@ -33,14 +35,14 @@ class ArchiveTest : BaseTestWithRepository() { testAccount.contentValues )!! ) - insertTransaction(testAccountId, 100) - insertTransaction(testAccountId, -200) - insertTransaction(testAccountId, 400) - insertTransaction(testAccountId, 800) } @Test fun createArchiveAndUnpack() { + insertTransaction(testAccountId, 100) + insertTransaction(testAccountId, -200) + insertTransaction(testAccountId, 400) + insertTransaction(testAccountId, 800) val archiveId = repository.archive(testAccountId, LocalDate.now() to LocalDate.now()) contentResolver.query( TransactionProvider.TRANSACTIONS_URI, @@ -55,12 +57,13 @@ class ArchiveTest : BaseTestWithRepository() { ContentUris.withAppendedId(TransactionProvider.TRANSACTIONS_URI, archiveId) contentResolver.query( archiveUri, - arrayOf(KEY_STATUS, KEY_AMOUNT), + arrayOf(KEY_STATUS, KEY_CR_STATUS, KEY_AMOUNT), null, null, null )!!.useAndAssert { movesToFirst() hasInt(0, STATUS_ARCHIVE) - hasLong(1, 1100) + hasString(1, CrStatus.UNRECONCILED.name) + hasLong(2, 1100) } contentResolver.query( TransactionProvider.TRANSACTIONS_URI, @@ -91,4 +94,67 @@ class ArchiveTest : BaseTestWithRepository() { } } } + + @Test(expected = IllegalStateException::class) + fun createArchiveWithInconsistentStates() { + insertTransaction(testAccountId, 100, crStatus = CrStatus.RECONCILED) + insertTransaction(testAccountId, -200, crStatus = CrStatus.CLEARED) + repository.archive(testAccountId, LocalDate.now() to LocalDate.now()) + } + + @Test + fun createArchiveWithVoidTransactions() { + insertTransaction(testAccountId, 100, crStatus = CrStatus.RECONCILED) + insertTransaction(testAccountId, -200, crStatus = CrStatus.VOID) + val archiveId = repository.archive(testAccountId, LocalDate.now() to LocalDate.now()) + contentResolver.query( + TransactionProvider.TRANSACTIONS_URI, + arrayOf(KEY_ROWID), + "$KEY_PARENTID is null", null, null + ).useAndAssert { + hasCount(1) + movesToFirst() + hasLong(0, archiveId) + } + val archiveUri = + ContentUris.withAppendedId(TransactionProvider.TRANSACTIONS_URI, archiveId) + contentResolver.query( + archiveUri, + arrayOf(KEY_STATUS, KEY_CR_STATUS, KEY_AMOUNT), + null, null, null + )!!.useAndAssert { + movesToFirst() + hasInt(0, STATUS_ARCHIVE) + hasString(1, CrStatus.RECONCILED.name) + hasLong(2, 100) + } + contentResolver.query( + TransactionProvider.TRANSACTIONS_URI, + arrayOf(KEY_STATUS, KEY_AMOUNT), + "$KEY_PARENTID = ?", arrayOf(archiveId.toString()), null + )!!.useAndAssert { + hasCount(2) + forEach { + hasInt(0, STATUS_ARCHIVED) + } + } + repository.unarchive(archiveId) + contentResolver.query( + archiveUri, + arrayOf(KEY_STATUS), + null, null, null + )!!.useAndAssert { + hasCount(0) + } + contentResolver.query( + TransactionProvider.TRANSACTIONS_URI, + arrayOf(KEY_STATUS), + null, null + )!!.useAndAssert { + hasCount(2) + forEach { + hasInt(0, STATUS_NONE) + } + } + } } \ No newline at end of file