diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt index 1bcd7d7b4..da3b620d6 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/dao/CreditCardDao.kt @@ -5,6 +5,7 @@ import androidx.room3.Query import androidx.room3.Transaction import androidx.room3.Upsert import de.davis.keygo.core.item.data.local.entity.CreditCardEntity +import de.davis.keygo.core.item.data.local.pojo.CreditCardExpirationProjection import de.davis.keygo.core.item.data.local.pojo.CreditCardProjection import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId @@ -27,4 +28,7 @@ internal interface CreditCardDao { @Transaction @Query("SELECT * FROM credit_card WHERE id IN (SELECT id FROM item WHERE vault_id = :vaultId)") suspend fun getByVault(vaultId: VaultId): List + + @Query("SELECT id, expiration_date FROM credit_card WHERE expiration_date IS NOT NULL") + fun observeExpirationDates(): Flow> } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/CreditCardExpirationProjection.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/CreditCardExpirationProjection.kt new file mode 100644 index 000000000..5f392a66d --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/local/pojo/CreditCardExpirationProjection.kt @@ -0,0 +1,11 @@ +package de.davis.keygo.core.item.data.local.pojo + +import androidx.room3.ColumnInfo +import de.davis.keygo.core.item.domain.alias.ItemId +import java.time.YearMonth + +internal data class CreditCardExpirationProjection( + val id: ItemId, + @ColumnInfo(name = "expiration_date") + val expirationDate: YearMonth, +) diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt index 3ac33b343..ca8c626d7 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/data/repository/CreditCardRepositoryImpl.kt @@ -15,6 +15,7 @@ import de.davis.keygo.core.util.Result import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.koin.core.annotation.Single +import java.time.YearMonth @Single internal class CreditCardRepositoryImpl( @@ -44,4 +45,9 @@ internal class CreditCardRepositoryImpl( override suspend fun getCreditCardsByVault(vaultId: VaultId): List = creditCardDao.getByVault(vaultId).map(CreditCardProjection::toDomain) + + override fun observeExpirationDates(): Flow> = + creditCardDao.observeExpirationDates().map { entries -> + entries.associate { it.id to it.expirationDate } + } } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatus.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatus.kt new file mode 100644 index 000000000..c673babac --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatus.kt @@ -0,0 +1,18 @@ +package de.davis.keygo.core.item.domain.model + +import java.time.YearMonth + +enum class CardExpiryStatus { + ExpiresThisMonth, + ExpiresNextMonth, + Expired; + + companion object { + fun of(expiration: YearMonth, today: YearMonth): CardExpiryStatus? = when { + expiration < today -> Expired + expiration == today -> ExpiresThisMonth + expiration == today.plusMonths(1) -> ExpiresNextMonth + else -> null + } + } +} diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt index e3ef029a1..67a8aceb3 100644 --- a/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/domain/repository/CreditCardRepository.kt @@ -5,6 +5,7 @@ import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.util.Result import kotlinx.coroutines.flow.Flow +import java.time.YearMonth interface CreditCardRepository { suspend fun createOrUpdateCreditCard(card: CreditCard): Result @@ -12,4 +13,6 @@ interface CreditCardRepository { fun observeCreditCardById(itemId: ItemId): Flow suspend fun getCreditCardById(itemId: ItemId): CreditCard? suspend fun getCreditCardsByVault(vaultId: VaultId): List + + fun observeExpirationDates(): Flow> } diff --git a/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/CardExpiryStatusText.kt b/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/CardExpiryStatusText.kt new file mode 100644 index 000000000..1c23a91dc --- /dev/null +++ b/core/item/src/main/kotlin/de/davis/keygo/core/item/presentation/CardExpiryStatusText.kt @@ -0,0 +1,40 @@ +package de.davis.keygo.core.item.presentation + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import de.davis.keygo.core.item.R +import de.davis.keygo.core.item.domain.model.CardExpiryStatus + +@Composable +fun CardExpiryStatusText( + status: CardExpiryStatus, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, +) { + Text( + text = status.label(), + modifier = modifier, + color = if (status.isEmphasized) MaterialTheme.colorScheme.error else Color.Unspecified, + style = style, + ) +} + +@Composable +fun CardExpiryStatus.label(): String = stringResource( + when (this) { + CardExpiryStatus.ExpiresThisMonth -> R.string.card_expires_this_month + CardExpiryStatus.ExpiresNextMonth -> R.string.card_expires_next_month + CardExpiryStatus.Expired -> R.string.card_expired + }, +) + +// A card about to expire is what the user can still act on; an expired one is often kept on +// purpose, so it stays quiet instead of turning every old card red. +val CardExpiryStatus.isEmphasized: Boolean + get() = this != CardExpiryStatus.Expired diff --git a/core/item/src/main/res/values/strings.xml b/core/item/src/main/res/values/strings.xml index 1f1e1ac74..52880577c 100644 --- a/core/item/src/main/res/values/strings.xml +++ b/core/item/src/main/res/values/strings.xml @@ -9,4 +9,8 @@ Moderate Strong Excellent + + Expires this month + Expires next month + Expired \ No newline at end of file diff --git a/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatusTest.kt b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatusTest.kt new file mode 100644 index 000000000..64902b8c8 --- /dev/null +++ b/core/item/src/test/kotlin/de/davis/keygo/core/item/domain/model/CardExpiryStatusTest.kt @@ -0,0 +1,47 @@ +package de.davis.keygo.core.item.domain.model + +import java.time.YearMonth +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CardExpiryStatusTest { + + private val today = YearMonth.of(2026, 9) + + @Test + fun `a card whose month has passed is expired`() { + assertEquals(CardExpiryStatus.Expired, CardExpiryStatus.of(YearMonth.of(2026, 8), today)) + assertEquals(CardExpiryStatus.Expired, CardExpiryStatus.of(YearMonth.of(2019, 1), today)) + } + + @Test + fun `a card is still valid through its expiry month`() { + assertEquals( + CardExpiryStatus.ExpiresThisMonth, + CardExpiryStatus.of(YearMonth.of(2026, 9), today), + ) + } + + @Test + fun `a card expiring the following month expires next month`() { + assertEquals( + CardExpiryStatus.ExpiresNextMonth, + CardExpiryStatus.of(YearMonth.of(2026, 10), today), + ) + } + + @Test + fun `next month rolls over the year boundary`() { + assertEquals( + CardExpiryStatus.ExpiresNextMonth, + CardExpiryStatus.of(YearMonth.of(2027, 1), YearMonth.of(2026, 12)), + ) + } + + @Test + fun `a card two or more months out has no status`() { + assertNull(CardExpiryStatus.of(YearMonth.of(2026, 11), today)) + assertNull(CardExpiryStatus.of(YearMonth.of(2030, 5), today)) + } +} diff --git a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt index cada680d0..e62685650 100644 --- a/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt +++ b/core/item/src/testFixtures/kotlin/de/davis/keygo/core/item/FakeCreditCardRepository.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import java.time.YearMonth /** * In-memory [CreditCardRepository] for tests. @@ -54,4 +55,11 @@ class FakeCreditCardRepository : CreditCardRepository { override suspend fun getCreditCardsByVault(vaultId: VaultId): List = store.value.values.filter { it.vaultId == vaultId } + + override fun observeExpirationDates(): Flow> = + store.map { cards -> + cards.values + .mapNotNull { card -> card.expirationDate?.let { card.id to it } } + .toMap() + } } diff --git a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt index 1c3299df1..0f3cbe668 100644 --- a/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt +++ b/core/ui/src/main/kotlin/de/davis/keygo/core/ui/components/KeyGoLazyColumn.kt @@ -45,7 +45,10 @@ import androidx.compose.ui.layout.positionInParent import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -64,8 +67,11 @@ data class KeyGoColumnItem( val title: String, val id: ID, val itemType: VaultItemType, + val status: ItemStatus? = null, ) +data class ItemStatus(val text: String, val emphasized: Boolean) + /** * The item the sticky header currently belongs to: [index] plus its [top] within the column. */ @@ -178,7 +184,10 @@ fun KeyGoColumn( } }, supportingContent = { - Text(text = item.itemType.presentation.first) + SupportingText( + typeLabel = item.itemType.presentation.first, + status = item.status, + ) }, selected = id in selectedItemIds, ) { @@ -276,6 +285,23 @@ private fun Modifier.expressiveAnimateItem(): Modifier = with(scope) { ) } +@Composable +private fun SupportingText(typeLabel: String, status: ItemStatus?) { + if (status == null) { + Text(text = typeLabel) + return + } + + val statusColor = if (status.emphasized) MaterialTheme.colorScheme.error else Color.Unspecified + Text( + text = buildAnnotatedString { + append(typeLabel) + append(" \u2022 ") + withStyle(SpanStyle(color = statusColor)) { append(status.text) } + }, + ) +} + @Composable private fun KeyGoInlineHeader( header: HeaderContent, diff --git a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt index 8f9acd79a..73ea40b30 100644 --- a/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt +++ b/feature/item/core/src/main/kotlin/de/davis/keygo/feature/item/core/presentation/component/KeyGoFormField.kt @@ -51,6 +51,7 @@ fun KeyGoFormField( trailingContent: @Composable (() -> Unit)? = null, outsideTrailingContent: @Composable (() -> Unit)? = null, error: InputFieldError? = null, + supportingText: @Composable (() -> Unit)? = null, lineLimits: TextFieldLineLimits = TextFieldLineLimits.SingleLine, keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next), onKeyboardAction: KeyboardActionHandler? = null, @@ -58,7 +59,7 @@ fun KeyGoFormField( outputTransformation: OutputTransformation? = null, interactionSource: MutableInteractionSource? = null, ) { - val supportingText: @Composable (() -> Unit)? = error?.let { + val errorText: @Composable (() -> Unit)? = error?.let { { val text = when (error) { InputFieldError.Empty -> stringResource(R.string.field_blank) @@ -97,8 +98,8 @@ fun KeyGoFormField( onClick = { passwordHidden = !passwordHidden }, ) }, - supportingText = supportingText, - isError = supportingText != null, + supportingText = errorText ?: supportingText, + isError = errorText != null, keyboardOptions = keyboardOptions, onKeyboardAction = onKeyboardAction, inputTransformation = inputTransformation, @@ -117,8 +118,8 @@ fun KeyGoFormField( prefix = prefix, trailingIcon = trailingContent, lineLimits = lineLimits, - supportingText = supportingText, - isError = supportingText != null, + supportingText = errorText ?: supportingText, + isError = errorText != null, keyboardOptions = keyboardOptions, onKeyboardAction = onKeyboardAction, inputTransformation = inputTransformation, diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt index d4f17c2c4..02a494323 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardContent.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.generated.domain.model.VaultItemType +import de.davis.keygo.core.item.presentation.CardExpiryStatusText import de.davis.keygo.feature.credit_card.presentation.CardScanEntry import de.davis.keygo.feature.item.core.presentation.component.CreateOrModifyItemTopAppBar import de.davis.keygo.feature.item.core.presentation.component.KeyGoFormField @@ -171,6 +172,9 @@ private fun CreditCardReadyContent( ), inputTransformation = ccExpirationDateInputTransformation, error = state.expirationDateError, + supportingText = state.expiryStatus?.let { status -> + @Composable { CardExpiryStatusText(status = status) } + }, ) } } diff --git a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt index 6e8b47382..7e0f0c1cb 100644 --- a/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt +++ b/feature/item/create/src/main/kotlin/de/davis/keygo/feature/item/create/presentation/creditcard/model/CreditCardUiState.kt @@ -2,8 +2,11 @@ package de.davis.keygo.feature.item.create.presentation.creditcard.model import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Stable +import de.davis.keygo.core.item.domain.model.CardExpiryStatus +import de.davis.keygo.core.item.domain.model.toYearMonthOrNull import de.davis.keygo.feature.item.core.presentation.model.InputFieldError import de.davis.keygo.feature.item.create.presentation.model.ItemUiState +import java.time.YearMonth internal typealias CreditCardUiState = ItemUiState @@ -25,6 +28,10 @@ internal data class CreditCardBaseState( || ccCVVTextFieldState.text.isNotBlank() || ccExpirationDateTextFieldState.text.isNotBlank() + val expiryStatus: CardExpiryStatus? + get() = ccExpirationDateTextFieldState.text.toString().toYearMonthOrNull() + ?.let { CardExpiryStatus.of(it, YearMonth.now()) } + fun canSave(name: CharSequence): Boolean = name.isNotBlank() && hasAnyContent } diff --git a/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardBaseStateTest.kt b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardBaseStateTest.kt new file mode 100644 index 000000000..bf270a849 --- /dev/null +++ b/feature/item/create/src/test/java/de/davis/keygo/feature/item/create/presentation/creditcard/CreditCardBaseStateTest.kt @@ -0,0 +1,46 @@ +package de.davis.keygo.feature.item.create.presentation.creditcard + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import de.davis.keygo.core.item.domain.model.CardExpiryStatus +import de.davis.keygo.feature.item.create.presentation.creditcard.model.CreditCardBaseState +import java.time.YearMonth +import java.time.format.DateTimeFormatter +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class CreditCardBaseStateTest { + + private val formatter = DateTimeFormatter.ofPattern("MM/yy") + + private fun stateWithExpiration(text: String) = CreditCardBaseState( + ccExpirationDateTextFieldState = TextFieldState(text), + ) + + @Test + fun `a past expiration reads as expired`() { + val text = YearMonth.now().minusMonths(1).format(formatter) + + assertEquals(CardExpiryStatus.Expired, stateWithExpiration(text).expiryStatus) + } + + @Test + fun `an incomplete or empty expiration has no status`() { + assertNull(stateWithExpiration("").expiryStatus) + assertNull(stateWithExpiration("0").expiryStatus) + assertNull(stateWithExpiration("13/30").expiryStatus) + } + + @Test + fun `typing a new date replaces the status`() { + val state = stateWithExpiration(YearMonth.now().format(formatter)) + assertEquals(CardExpiryStatus.ExpiresThisMonth, state.expiryStatus) + + state.ccExpirationDateTextFieldState.setTextAndPlaceCursorAtEnd( + YearMonth.now().plusYears(3).format(formatter), + ) + + assertNull(state.expiryStatus) + } +} diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt index 7875b9975..10e8906cd 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardContent.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.item.view.creditcard import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.consumeWindowInsets @@ -57,6 +58,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import de.davis.keygo.core.item.presentation.CardExpiryStatusText import de.davis.keygo.core.item.presentation.toImageVector import de.davis.keygo.core.ui.components.VisibilityButton import de.davis.keygo.core.ui.composition.LocalIsInSinglePaneMode @@ -235,7 +237,15 @@ fun ViewCreditCardContent(state: ViewCreditCardState, onEvent: (ViewCreditCardUi leadingIcon = Icons.Default.CalendarMonth, dataToCopy = { state.expirationDate }, ) { - Text(text = state.expirationDate) + Column { + Text(text = state.expirationDate) + state.expiryStatus?.let { + CardExpiryStatusText( + status = it, + style = MaterialTheme.typography.bodyMedium, + ) + } + } } } diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt index 4ac694c16..99d9345c6 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModel.kt @@ -3,6 +3,7 @@ package de.davis.keygo.feature.item.view.creditcard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.repository.CreditCardRepository import de.davis.keygo.core.item.domain.repository.ItemRepository @@ -51,6 +52,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koin.core.annotation.KoinViewModel +import java.time.YearMonth import java.time.format.DateTimeFormatter @KoinViewModel @@ -101,6 +103,9 @@ internal class ViewCreditCardViewModel( cardNumber = number, cvv = cvv, expirationDate = card.expirationDate?.format(EXPIRATION_FORMATTER).orEmpty(), + expiryStatus = card.expirationDate?.let { + CardExpiryStatus.of(it, YearMonth.now()) + }, tags = sort(card.tags) { it.display }.toSet(), note = card.note.orEmpty(), pinned = card.pinned, diff --git a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt index 924a89830..c94c0afa8 100644 --- a/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt +++ b/feature/item/view/src/main/kotlin/de/davis/keygo/feature/item/view/creditcard/model/ViewCreditCardState.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.item.view.creditcard.model import androidx.compose.runtime.Immutable +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.Tag import de.davis.keygo.core.item.domain.model.VaultMetadata import de.davis.keygo.feature.item.view.login.model.ObfuscatedString @@ -13,6 +14,7 @@ data class ViewCreditCardState( val cardNumber: ObfuscatedString? = null, val cvv: ObfuscatedString? = null, val expirationDate: String = "", + val expiryStatus: CardExpiryStatus? = null, val tags: Set = emptySet(), val note: String = "", val modificationDialog: ModificationDialog? = null, diff --git a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt index 41d59d7f8..3d5f4f0a1 100644 --- a/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt +++ b/feature/item/view/src/test/kotlin/de/davis/keygo/feature/item/view/creditcard/ViewCreditCardViewModelTest.kt @@ -7,6 +7,7 @@ import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.FakeVaultRepository import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.EncryptedPayload import de.davis.keygo.core.item.domain.model.KeyInformation @@ -18,6 +19,7 @@ import de.davis.keygo.core.security.crypto.FakeCryptographicScopeProvider import de.davis.keygo.core.security.domain.usecase.ItemWithCryptoScopeUseCase import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.item.core.domain.usecase.CreateNewOrUpdateCreditCardUseCase +import de.davis.keygo.feature.item.view.creditcard.model.ViewCreditCardState import de.davis.keygo.feature.item.view.login.model.ObfuscatedString import de.davis.keygo.rust.FakeCardFormatter import kotlinx.coroutines.Dispatchers @@ -28,10 +30,12 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import java.time.YearMonth import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class ViewCreditCardViewModelTest { @@ -110,6 +114,38 @@ class ViewCreditCardViewModelTest { assertEquals("**** **** **** 1111".replace('*', '\u2022'), cardNumber.hidden) } + @Test + fun `an expired card shows as expired`() = runTest(dispatcher) { + creditCardRepository.seed( + creditCard.copy(expirationDate = YearMonth.now().minusMonths(1)), + ) + + assertEquals(CardExpiryStatus.Expired, awaitState().expiryStatus) + } + + @Test + fun `a card expiring next month says so`() = runTest(dispatcher) { + creditCardRepository.seed( + creditCard.copy(expirationDate = YearMonth.now().plusMonths(1)), + ) + + assertEquals(CardExpiryStatus.ExpiresNextMonth, awaitState().expiryStatus) + } + + @Test + fun `a card without an expiration date has no status`() = runTest(dispatcher) { + assertNull(awaitState().expiryStatus) + } + + private suspend fun awaitState(): ViewCreditCardState { + val vm = makeViewModel().also { it.init(itemId) } + try { + return vm.state.first { it.name.isNotEmpty() } + } finally { + vm.viewModelScope.cancel() + } + } + private suspend fun awaitCardNumber(): ObfuscatedString { val vm = makeViewModel().also { it.init(itemId) } try { diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCase.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCase.kt new file mode 100644 index 000000000..74e8d0be1 --- /dev/null +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCase.kt @@ -0,0 +1,25 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus +import de.davis.keygo.core.item.domain.repository.CreditCardRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.koin.core.annotation.Single +import java.time.YearMonth + +@Single +class ObserveCardExpiryStatusesUseCase( + private val creditCardRepository: CreditCardRepository, +) { + + operator fun invoke(): Flow> = + creditCardRepository.observeExpirationDates().map { expirations -> + val today = YearMonth.now() + buildMap { + expirations.forEach { (id, expiration) -> + CardExpiryStatus.of(expiration, today)?.let { put(id, it) } + } + } + } +} diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt index f6c8bec62..1879c3ea7 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModel.kt @@ -16,6 +16,7 @@ import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.util.combine import de.davis.keygo.feature.list_screen.domain.model.FilterState import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase +import de.davis.keygo.feature.list_screen.domain.usecase.ObserveCardExpiryStatusesUseCase import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.list_screen.presentation.mapper.toAvailableFilterOptions import de.davis.keygo.feature.list_screen.presentation.mapper.toBottomSheetState @@ -65,6 +66,7 @@ internal class ItemListViewModel( private val rankSearchResults: RankSearchResultsUseCase, observeAllTags: ObserveAllTagsSortedUseCase, observeVaultsAndSelection: ObserveVaultsAndSelectionUseCase, + observeCardExpiryStatuses: ObserveCardExpiryStatusesUseCase, loginRepository: LoginRepository, ) : ViewModel() { @@ -84,6 +86,8 @@ internal class ItemListViewModel( private val passwordScores = loginRepository.observePasswordScores() + private val cardExpiryStatuses = observeCardExpiryStatuses().distinctUntilChanged() + private val filterState = MutableStateFlow(FilterState.Default) @OptIn(ExperimentalCoroutinesApi::class) @@ -131,9 +135,11 @@ internal class ItemListViewModel( submittedSearchQuery, _isVaultFlowVisible, _isDeleteConfirmationVisible, - ) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible -> + cardExpiryStatuses, + ) { vaultsAndSel, items, searchState, selection, submittedSearchQuery, isVaultFlowVisible, isDeleteConfirmationVisible, expiryStatuses -> ListItemState( items = items, + cardExpiryStatuses = expiryStatuses, searchState = searchState, hasSearchQuery = submittedSearchQuery.isNotBlank(), selection = selection, diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt index 226fdc008..571f01997 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/components/ItemListContent.kt @@ -38,11 +38,15 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.lite.LiteItemSearchResult import de.davis.keygo.core.item.generated.domain.model.VaultItemType import de.davis.keygo.core.item.generated.presentation.presentation +import de.davis.keygo.core.item.presentation.isEmphasized +import de.davis.keygo.core.item.presentation.label import de.davis.keygo.core.ui.R import de.davis.keygo.core.ui.components.HeaderContent +import de.davis.keygo.core.ui.components.ItemStatus import de.davis.keygo.core.ui.components.KeyGoCard import de.davis.keygo.core.ui.components.KeyGoCardProperties import de.davis.keygo.core.ui.components.KeyGoColumn @@ -218,7 +222,13 @@ internal fun ItemListContent( } false -> { - val items = remember(uiState.items, suggestedItemIds) { + val expiryLabels = CardExpiryStatus.entries.associateWith { it.label() } + val items = remember( + uiState.items, + suggestedItemIds, + uiState.cardExpiryStatuses, + expiryLabels, + ) { uiState.items.map { KeyGoColumnItem( header = when { @@ -229,6 +239,12 @@ internal fun ItemListContent( title = it.name, id = it.id, itemType = it.itemType, + status = uiState.cardExpiryStatuses[it.id]?.let { status -> + ItemStatus( + text = expiryLabels.getValue(status), + emphasized = status.isEmphasized, + ) + }, ) } } diff --git a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt index 466a95f23..8ef5eafea 100644 --- a/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt +++ b/feature/list_screen/src/main/kotlin/de/davis/keygo/feature/list_screen/presentation/model/ListItemState.kt @@ -2,6 +2,7 @@ package de.davis.keygo.feature.list_screen.presentation.model import androidx.compose.runtime.Stable import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus import de.davis.keygo.core.item.domain.model.VaultContext import de.davis.keygo.core.item.domain.model.VaultMetadata import de.davis.keygo.core.item.domain.model.lite.LiteItem @@ -9,6 +10,7 @@ import de.davis.keygo.core.item.domain.model.lite.LiteItem @Stable internal data class ListItemState( val items: List = emptyList(), + val cardExpiryStatuses: Map = emptyMap(), val searchState: SearchState = SearchState(), val hasSearchQuery: Boolean = false, val selection: ItemSelection = ItemSelection(), diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCaseTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCaseTest.kt new file mode 100644 index 000000000..a830fee07 --- /dev/null +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/domain/usecase/ObserveCardExpiryStatusesUseCaseTest.kt @@ -0,0 +1,77 @@ +package de.davis.keygo.feature.list_screen.domain.usecase + +import de.davis.keygo.core.item.FakeCreditCardRepository +import de.davis.keygo.core.item.domain.alias.ItemId +import de.davis.keygo.core.item.domain.alias.newItemId +import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus +import de.davis.keygo.core.item.domain.model.CreditCard +import de.davis.keygo.core.item.domain.model.KeyInformation +import de.davis.keygo.core.item.domain.model.Timestamp +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import java.time.YearMonth +import kotlin.test.Test +import kotlin.test.assertEquals + +class ObserveCardExpiryStatusesUseCaseTest { + + private val creditCardRepository = FakeCreditCardRepository() + private val useCase = ObserveCardExpiryStatusesUseCase(creditCardRepository) + + private fun card(expirationDate: YearMonth?) = CreditCard( + id = newItemId(), + vaultId = newVaultId(), + name = "Card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), + tags = emptySet(), + note = null, + pinned = false, + holder = null, + cardNumber = null, + cvv = null, + expirationDate = expirationDate, + ) + + @Test + fun `only cards that are expired or about to expire get a status`() = runTest { + val today = YearMonth.now() + val expired = card(today.minusMonths(1)) + val expiresThisMonth = card(today) + val expiresNextMonth = card(today.plusMonths(1)) + val farOut = card(today.plusYears(3)) + val undated = card(null) + creditCardRepository.seed(expired, expiresThisMonth, expiresNextMonth, farOut, undated) + + assertEquals( + mapOf( + expired.id to CardExpiryStatus.Expired, + expiresThisMonth.id to CardExpiryStatus.ExpiresThisMonth, + expiresNextMonth.id to CardExpiryStatus.ExpiresNextMonth, + ), + useCase().first(), + ) + } + + @Test + fun `renewing a card clears its status`() = runTest { + val today = YearMonth.now() + val renewed = card(today.minusMonths(2)) + creditCardRepository.seed(renewed) + val emissions = mutableListOf>() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + useCase().toList(emissions) + } + + creditCardRepository.seed(renewed.copy(expirationDate = today.plusYears(4))) + + assertEquals( + listOf(mapOf(renewed.id to CardExpiryStatus.Expired), emptyMap()), + emissions, + ) + } +} diff --git a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt index 29543725f..7da35ae85 100644 --- a/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt +++ b/feature/list_screen/src/test/kotlin/de/davis/keygo/feature/list_screen/presentation/ItemListViewModelTest.kt @@ -1,6 +1,7 @@ package de.davis.keygo.feature.list_screen.presentation import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import de.davis.keygo.core.item.FakeCreditCardRepository import de.davis.keygo.core.item.FakeItemRepository import de.davis.keygo.core.item.FakeLoginRepository import de.davis.keygo.core.item.FakeVaultContextRepository @@ -9,12 +10,15 @@ import de.davis.keygo.core.item.domain.alias.ItemId import de.davis.keygo.core.item.domain.alias.VaultId import de.davis.keygo.core.item.domain.alias.newItemId import de.davis.keygo.core.item.domain.alias.newVaultId +import de.davis.keygo.core.item.domain.model.CardExpiryStatus +import de.davis.keygo.core.item.domain.model.CreditCard import de.davis.keygo.core.item.domain.model.KeyInformation import de.davis.keygo.core.item.domain.model.Login import de.davis.keygo.core.item.domain.model.Timestamp import de.davis.keygo.core.item.domain.usecase.ObserveAllTagsSortedUseCase import de.davis.keygo.core.util.domain.usecase.SortUseCase import de.davis.keygo.feature.list_screen.domain.usecase.FilterUseCase +import de.davis.keygo.feature.list_screen.domain.usecase.ObserveCardExpiryStatusesUseCase import de.davis.keygo.feature.list_screen.domain.usecase.RankSearchResultsUseCase import de.davis.keygo.feature.list_screen.presentation.model.Event import de.davis.keygo.feature.vault.domain.usecase.ObserveVaultsAndSelectionUseCase @@ -29,6 +33,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain +import java.time.YearMonth import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -50,6 +55,7 @@ class ItemListViewModelTest { private val itemRepository = FakeItemRepository(loginRepository) private val vaultRepository = FakeVaultRepository() private val vaultContextRepository = FakeVaultContextRepository() + private val creditCardRepository = FakeCreditCardRepository() private val sortUseCase = SortUseCase() @BeforeTest @@ -70,6 +76,7 @@ class ItemListViewModelTest { vaultContextRepository = vaultContextRepository, sortUseCase = sortUseCase, ), + observeCardExpiryStatuses = ObserveCardExpiryStatusesUseCase(creditCardRepository), loginRepository = loginRepository, ) @@ -418,6 +425,36 @@ class ItemListViewModelTest { assertEquals(Event.ItemSelected(opened.id), selected.await()) } + + @Test + fun `an expired card carries its status into the list state`() = runTest(dispatcher) { + val card = expiredCard() + creditCardRepository.seed(card) + + val vm = viewModel() + backgroundScope.launchCollect(vm) + advanceUntilIdle() + + assertEquals( + mapOf(card.id to CardExpiryStatus.Expired), + vm.listItemState.value.cardExpiryStatuses, + ) + } + + private fun expiredCard() = CreditCard( + id = newItemId(), + vaultId = vaultId, + name = "Old card", + keyInformation = KeyInformation(byteArrayOf(), byteArrayOf()), + timestamp = Timestamp(), + tags = emptySet(), + note = null, + pinned = false, + holder = null, + cardNumber = null, + cvv = null, + expirationDate = YearMonth.now().minusYears(1), + ) } /**