Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<CreditCardProjection>

@Query("SELECT id, expiration_date FROM credit_card WHERE expiration_date IS NOT NULL")
fun observeExpirationDates(): Flow<List<CreditCardExpirationProjection>>
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -44,4 +45,9 @@ internal class CreditCardRepositoryImpl(

override suspend fun getCreditCardsByVault(vaultId: VaultId): List<CreditCard> =
creditCardDao.getByVault(vaultId).map(CreditCardProjection::toDomain)

override fun observeExpirationDates(): Flow<Map<ItemId, YearMonth>> =
creditCardDao.observeExpirationDates().map { entries ->
entries.associate { it.id to it.expirationDate }
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ 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<ItemId, Throwable>

fun observeCreditCardById(itemId: ItemId): Flow<CreditCard?>
suspend fun getCreditCardById(itemId: ItemId): CreditCard?
suspend fun getCreditCardsByVault(vaultId: VaultId): List<CreditCard>

fun observeExpirationDates(): Flow<Map<ItemId, YearMonth>>
}
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions core/item/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,8 @@
<string name="password_strength_moderate">Moderate</string>
<string name="password_strength_strong">Strong</string>
<string name="password_strength_excellent">Excellent</string>

<string name="card_expires_this_month">Expires this month</string>
<string name="card_expires_next_month">Expires next month</string>
<string name="card_expired">Expired</string>
</resources>
Original file line number Diff line number Diff line change
@@ -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))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -54,4 +55,11 @@ class FakeCreditCardRepository : CreditCardRepository {

override suspend fun getCreditCardsByVault(vaultId: VaultId): List<CreditCard> =
store.value.values.filter { it.vaultId == vaultId }

override fun observeExpirationDates(): Flow<Map<ItemId, YearMonth>> =
store.map { cards ->
cards.values
.mapNotNull { card -> card.expirationDate?.let { card.id to it } }
.toMap()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -64,8 +67,11 @@ data class KeyGoColumnItem<ID : Any>(
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.
*/
Expand Down Expand Up @@ -178,7 +184,10 @@ fun <ID : Any> KeyGoColumn(
}
},
supportingContent = {
Text(text = item.itemType.presentation.first)
SupportingText(
typeLabel = item.itemType.presentation.first,
status = item.status,
)
},
selected = id in selectedItemIds,
) {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,15 @@ 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,
inputTransformation: InputTransformation? = TrimTransformation,
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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -171,6 +172,9 @@ private fun CreditCardReadyContent(
),
inputTransformation = ccExpirationDateInputTransformation,
error = state.expirationDateError,
supportingText = state.expiryStatus?.let { status ->
@Composable { CardExpiryStatusText(status = status) }
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreditCardBaseState>

Expand All @@ -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
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading