K2: card purchases on Android (Room 2, card cycle, :feature:cards S-23 to S-27)

- :core:model model/Cards.kt per SPEC §14.2.4 "Changes in v0.35"; ContractTest covers the card wire values.
- :domain matchCardPurchase (§14.5.8), passing match_card_purchase.json (17 cases) in VectorTest.
- :data Room version 2 (card_connections, card_purchases, card_sync_state, MIGRATION_1_2 with its test,
  2.json exported); DTOs for E-36 to E-44 round-tripping K1's examples; the card cycle (E-44 push of
  pending link changes, E-40 pull) after each household cycle and after refresh; CardRepositoryImpl with
  CP-9 prefill, CP-10 matches, offline-first link/unlink/ignore; a local expense delete unlinks.
- :core:fakes FakeCardRepository and fakes/cards scenarios for every state of S-23 to S-27.
- :feature:cards S-23 (sheet and "Välj en annan utgift"), S-24, S-25 (Custom Tab, E-38 every 2 s while
  resumed, return link), S-26 (disconnect dialog), S-27, CardArt with the SJ Prio picture and the drawn
  fallback; 135 goldens and state-holder tests.
- :feature:expense S-7 cardPurchaseId prefill and link on save, S-8 "Från kortköp" line;
  :feature:account S-5 "Från kortköp"; :feature:settings S-13 "Anslut kort".
- :core:design KvittDestination.Cards with credit_card and nav_cards (four destinations, D-172); the
  bar's labels shrink to fit at large font scales. :app routes, kvitt://cards/connected, bindings.
- docs/ux/strings.csv: the card keys of §13.3 plus K2's additions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-24 16:49:47 +02:00
parent 07df30d04d
commit a8f91ee6ea
391 changed files with 6889 additions and 54 deletions
+12
View File
@@ -0,0 +1,12 @@
// :feature:cards — card purchases (D-173): the Kortköp tab with the purchase sheet (S-23), Anslut kort (S-24), connect
// with BankID (S-25), the connection's settings (S-26), "Från kortköp" (S-27) and the card artwork (CardArt).
// Convention: android/README.md; :core:model, :core:design, :core:fakes (debug and tests), :core:design's test fixtures
// and the screenshot tests come from kvitt.android.feature.
plugins {
id("kvitt.android.feature")
}
dependencies {
// S-25: the bank's BankID page opens in a Custom Tab (D-173).
implementation(libs.androidx.browser)
}
@@ -0,0 +1,64 @@
package se.hagstedt.kvitt.feature.cards
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.design.theme.KvittLightColors
import se.hagstedt.kvitt.model.ColorToken
/**
* A card's artwork (D-173, D-180, CP-1): the bundled picture for a known [imageKey], drawn at its own aspect ratio inside
* the card box (`ContentScale.Fit`) without clipping or a background, so its own rounded corners and notch show. Any
* other key draws the fallback card: 1.586:1, radius 16, the `green` category token's light value in both themes, and
* the card's [name] in white (4.95:1), no other text. TalkBack reads [name].
*/
@Composable
fun CardArt(imageKey: String, name: String, modifier: Modifier = Modifier) {
val box = modifier.aspectRatio(KvittDimens.CARD_ART_ASPECT).clearAndSetSemantics { if (name.isNotEmpty()) contentDescription = name }
val artwork = cardArtwork(imageKey)
if (artwork != null) {
Box(box, contentAlignment = Alignment.Center) {
Image(painterResource(artwork), contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
}
} else {
val shape = RoundedCornerShape(KvittDimens.cardArtCorner)
Box(box.clip(shape).background(KvittLightColors.category(ColorToken.GREEN), shape), contentAlignment = Alignment.BottomStart) {
Text(
name,
Modifier.padding(KvittDimens.cardArtNameInset),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = KvittLightColors.onCat,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** The bundled artwork of an image key (CP-1); null for a key the app has no picture for. */
@DrawableRes
fun cardArtwork(imageKey: String): Int? = when (imageKey) {
SJ_PRIO_MASTERCARD -> R.drawable.card_sj_prio_mastercard
else -> null
}
/** The image key of the registry's SJ Prio Mastercard (CP-1); the only bundled picture (D-180). */
const val SJ_PRIO_MASTERCARD = "sj_prio_mastercard"
@@ -0,0 +1,168 @@
package se.hagstedt.kvitt.feature.cards
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.core.design.R as DesignR
/** What S-25 can ask its state holder to do; [CardConnectViewModel] implements it through the screen. */
data class CardConnectActions(
val onBack: () -> Unit = {},
val onHistory: (Int) -> Unit = {},
val onStart: () -> Unit = {},
val onCancel: () -> Unit = {},
)
/**
* S-25 Connect a card (pushed from S-24, or from "Förnya med BankID"): the history choice (CP-3) and "Anslut med
* BankID"; the bank's page opens in a Custom Tab (`androidx.browser`, D-173). [returnedAttempt] is the attempt id of a
* return link `kvitt://cards/connected?attempt=<id>` that arrived while this screen is open (`:app` passes it).
* [onConnected] goes to S-23 after "Kortet är anslutet".
*/
@Composable
fun CardConnectScreen(
onBack: () -> Unit,
onConnected: () -> Unit,
modifier: Modifier = Modifier,
returnedAttempt: String? = null,
viewModel: CardConnectViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(viewModel) {
viewModel.eventFlow.collect { event ->
when (event) {
is ConnectEvent.OpenBank -> if (!openBankPage(context, event.url)) viewModel.bankPageFailed()
ConnectEvent.Done -> onConnected()
}
}
}
LaunchedEffect(returnedAttempt) { returnedAttempt?.let(viewModel::onReturn) }
// CP-2: E-38 is polled while this screen is open and resumed (the Custom Tab on top pauses it).
LifecycleResumeEffect(viewModel) {
viewModel.onResume()
onPauseOrDispose { viewModel.onPause() }
}
CardConnectContent(ui, CardConnectActions(onBack, viewModel::setHistory, viewModel::start, viewModel::cancel), modifier)
}
/** Opens [url] in a Custom Tab; false when no browser can show it. */
private fun openBankPage(context: Context, url: String): Boolean = try {
CustomTabsIntent.Builder().build().launchUrl(context, Uri.parse(url))
true
} catch (e: ActivityNotFoundException) {
false
}
/** The stateless S-25, rendered by [CardConnectScreen] and the screenshot tests. */
@Composable
fun CardConnectContent(ui: CardConnectUi, actions: CardConnectActions, modifier: Modifier = Modifier) {
val provider = ui.provider
KvittScaffold(modifier, topBar = { KvittTopAppBar(provider?.name.orEmpty(), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) }) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s5),
) {
if (provider != null) CardArt(provider.imageKey, provider.name, Modifier.width(KvittDimens.cardArtLarge))
when (ui.phase) {
ConnectPhase.Choose, ConnectPhase.Starting -> Choose(ui, actions)
ConnectPhase.Waiting -> Waiting(actions)
ConnectPhase.Connected -> Outcome(KvittIcon.CheckCircle, stringResource(R.string.cards_connected), null, null)
ConnectPhase.Failed -> Outcome(KvittIcon.Error, stringResource(R.string.cards_connect_failed), stringResource(DesignR.string.common_retry), actions.onStart)
ConnectPhase.TimedOut -> Outcome(KvittIcon.Schedule, stringResource(R.string.cards_connect_timeout), stringResource(DesignR.string.common_retry), actions.onStart)
}
}
}
}
@Composable
private fun Choose(ui: CardConnectUi, actions: CardConnectActions) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
val label = stringResource(R.string.cards_history_label)
Text(label, style = MaterialTheme.typography.titleSmall)
HistoryChips(HISTORY_OPTIONS, ui.historyDays, actions.onHistory, label)
Text(stringResource(R.string.cards_history_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (ui.couldNotReach) {
Banner(BannerKind.Error, KvittIcon.Error, stringResource(DesignR.string.common_could_not_reach_server))
}
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
KvittButton(
stringResource(if (ui.renew) R.string.cards_renew else R.string.cards_connect_bankid),
actions.onStart,
Modifier.fillMaxWidth(),
size = ButtonSize.Medium,
icon = KvittIcon.Key,
enabled = ui.online,
loading = ui.phase == ConnectPhase.Starting,
)
if (!ui.online) NeedsConnectionHint()
}
}
}
@Composable
private fun Waiting(actions: CardConnectActions) {
Column(
Modifier.fillMaxWidth().semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.cards_waiting), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
Text(stringResource(R.string.cards_waiting_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
}
KvittButton(stringResource(DesignR.string.common_cancel), actions.onCancel, kind = ButtonKind.Text)
}
@Composable
private fun Outcome(icon: KvittIcon, title: String, action: String?, onAction: (() -> Unit)?) {
Column(
Modifier.fillMaxWidth().semantics(mergeDescendants = true) { liveRegion = LiveRegionMode.Polite },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
KvittIconImage(icon, null, filled = true, size = KvittDimens.emptyIconTile, tint = MaterialTheme.colorScheme.primary)
Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
}
if (action != null && onAction != null) KvittButton(action, onAction, size = ButtonSize.Medium, icon = KvittIcon.Refresh)
}
@@ -0,0 +1,203 @@
package se.hagstedt.kvitt.feature.cards
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.CardAttemptState
import se.hagstedt.kvitt.model.CardConnectionStatus
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardProvider
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import javax.inject.Inject
/** Where S-25 is in the CP-2 flow. */
enum class ConnectPhase { Choose, Starting, Waiting, Connected, Failed, TimedOut }
/** One-off results of S-25: open the bank's page in a Custom Tab, or go on to S-23. */
sealed interface ConnectEvent {
data class OpenBank(val url: String) : ConnectEvent
data object Done : ConnectEvent
}
/** What S-25 shows. [renew]: "Förnya med BankID" with the history preset to the connection's (CP-6). */
data class CardConnectUi(
val provider: CardProvider? = null,
val historyDays: Int = DEFAULT_HISTORY_DAYS,
val renew: Boolean = false,
val phase: ConnectPhase = ConnectPhase.Choose,
val online: Boolean = true,
val couldNotReach: Boolean = false,
)
/** CP-3: "Hämta köp från de senaste 7 / 30 / 90 / 180 dagar", default 30. */
val HISTORY_OPTIONS = listOf(7, 30, 90, 180)
const val DEFAULT_HISTORY_DAYS = 30
private data class ConnectLocal(
val historyDays: Int? = null,
val phase: ConnectPhase = ConnectPhase.Choose,
val attemptId: String? = null,
val couldNotReach: Boolean = false,
)
/**
* S-25 state holder (CP-2): E-37 creates the attempt and the bank URL opens in a Custom Tab; while S-25 is open and
* resumed it polls E-38 every 2 seconds ([POLL_MILLIS]), and once more at once when the return link
* `kvitt://cards/connected?attempt=<id>` opens it. Connected shows "Kortet är anslutet" and then goes to S-23; failed and
* expired (the server's timeout, 15 minutes) offer "Försök igen"; "Avbryt" stops polling (the attempt expires by
* itself). Arguments: [ARG_PROVIDER_ID], [ARG_RENEW], [ARG_ATTEMPT].
*/
@HiltViewModel
class CardConnectViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val cards: CardRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val providerId: String = savedStateHandle.get<String>(ARG_PROVIDER_ID).orEmpty()
private val renew: Boolean = savedStateHandle.get<String>(ARG_RENEW) == "true"
private val local = MutableStateFlow(ConnectLocal(attemptId = savedStateHandle.get<String>(ARG_ATTEMPT)))
private val events = Channel<ConnectEvent>(Channel.BUFFERED)
private var resumed = false
private var polling: Job? = null
val eventFlow: Flow<ConnectEvent> = events.receiveAsFlow()
val ui: StateFlow<CardConnectUi> = combine(cards.cards(CardFilter.ALL), connectivity.online, local) { c, online, l ->
val provider = c.provider?.takeIf { it.id == providerId } ?: CardProvider(providerId, "", "", providerId, true)
val connected = c.connection?.takeIf { it.providerId == providerId && it.status != CardConnectionStatus.DISCONNECTED }
CardConnectUi(
provider = provider,
historyDays = l.historyDays ?: connected?.historyDays?.takeIf { renew && it in HISTORY_OPTIONS } ?: DEFAULT_HISTORY_DAYS,
renew = renew,
phase = l.phase,
online = online,
couldNotReach = l.couldNotReach,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, CardConnectUi(renew = renew))
init {
// Opened by the return link (S-25 was not on the stack): ask the attempt at once.
if (local.value.attemptId != null) {
local.update { it.copy(phase = ConnectPhase.Waiting) }
pollOnce()
}
}
fun setHistory(days: Int) = local.update { it.copy(historyDays = days) }
/** "Anslut med BankID" / "Förnya med BankID" and "Försök igen". */
fun start() {
if (local.value.phase == ConnectPhase.Starting) return
local.update { it.copy(phase = ConnectPhase.Starting, couldNotReach = false) }
viewModelScope.launch {
when (val result = cards.startConnect(providerId, ui.value.historyDays)) {
is OnlineResult.Ok -> {
local.update { it.copy(phase = ConnectPhase.Waiting, attemptId = result.value.attemptId) }
events.send(ConnectEvent.OpenBank(result.value.authUrl))
startPolling()
}
OnlineResult.Offline -> local.update { it.copy(phase = ConnectPhase.Choose) }
is OnlineResult.Failed -> local.update {
if (result.code == NETWORK) it.copy(phase = ConnectPhase.Choose, couldNotReach = true) else it.copy(phase = ConnectPhase.Failed)
}
}
}
}
/** "Avbryt": stop polling and return to the choice (the attempt expires by itself). */
fun cancel() {
polling?.cancel()
local.update { it.copy(phase = ConnectPhase.Choose, attemptId = null) }
}
/** The Custom Tab could not open (no browser on the phone). */
fun bankPageFailed() {
polling?.cancel()
local.update { it.copy(phase = ConnectPhase.Failed) }
}
/** The return link named [attemptId]: poll now (CP-2, AC-22.2's 5 seconds). */
fun onReturn(attemptId: String) {
val current = local.value
if (current.attemptId != null && current.attemptId != attemptId) return
local.update { it.copy(attemptId = attemptId, phase = if (it.phase == ConnectPhase.Choose) ConnectPhase.Waiting else it.phase) }
pollOnce()
}
fun onResume() {
resumed = true
startPolling()
}
fun onPause() {
resumed = false
polling?.cancel()
}
private fun startPolling() {
if (!resumed || local.value.phase != ConnectPhase.Waiting) return
polling?.cancel()
polling = viewModelScope.launch {
while (isActive && local.value.phase == ConnectPhase.Waiting) {
delay(POLL_MILLIS)
check()
}
}
}
private fun pollOnce() {
viewModelScope.launch { check() }
}
private suspend fun check() {
val attempt = local.value.attemptId ?: return
val result = cards.attempt(attempt) as? OnlineResult.Ok ?: return
when (result.value) {
CardAttemptState.Pending -> Unit
is CardAttemptState.Connected -> connected()
is CardAttemptState.Failed -> stop(ConnectPhase.Failed)
CardAttemptState.Expired -> stop(ConnectPhase.TimedOut)
}
}
/** The polling loop ends by itself once the phase is no longer [ConnectPhase.Waiting]. */
private fun connected() {
if (local.value.phase == ConnectPhase.Connected) return
stop(ConnectPhase.Connected)
viewModelScope.launch {
delay(CONNECTED_MILLIS)
events.send(ConnectEvent.Done)
}
}
private fun stop(phase: ConnectPhase) = local.update { it.copy(phase = phase) }
companion object {
const val ARG_PROVIDER_ID = "providerId"
const val ARG_RENEW = "renew"
const val ARG_ATTEMPT = "attempt"
/** CP-2: "polling E-38 every 2 seconds while its connect screen is open". */
const val POLL_MILLIS = 2_000L
/** "Kortet är anslutet" stays this long before S-23 opens. */
const val CONNECTED_MILLIS = 1_500L
private const val NETWORK = "network"
}
}
@@ -0,0 +1,269 @@
package se.hagstedt.kvitt.feature.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.relativeTime
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.CardConnection
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardConnectionStatus
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardProvider
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import javax.inject.Inject
import se.hagstedt.kvitt.core.design.R as DesignR
/** What S-26 shows; [connection] null once it is gone (disconnected here or elsewhere). */
data class CardConnectionUi(
val provider: CardProvider? = null,
val connection: CardConnection? = null,
val online: Boolean = true,
val disconnectDialog: Boolean = false,
val eraseHandled: Boolean = false,
val busy: Boolean = false,
val couldNotReach: Boolean = false,
) {
/** Every action of S-26 needs the server (§9.9). */
val canAct: Boolean get() = online && !busy
}
/** One-off results of S-26. */
sealed interface ConnectionEvent {
data object Disconnected : ConnectionEvent
}
private data class ConnectionLocal(val dialog: Boolean = false, val erase: Boolean = false, val busy: Boolean = false, val couldNotReach: Boolean = false)
/** S-26 state holder (CP-3, CP-6, CP-12). Argument: [ARG_CONNECTION_ID]. */
@HiltViewModel
class CardConnectionViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val cards: CardRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val id = CardConnectionId(savedStateHandle.get<String>(ARG_CONNECTION_ID).orEmpty())
private val local = MutableStateFlow(ConnectionLocal())
private val events = Channel<ConnectionEvent>(Channel.BUFFERED)
val eventFlow: Flow<ConnectionEvent> = events.receiveAsFlow()
val ui: StateFlow<CardConnectionUi> = combine(cards.cards(CardFilter.ALL), connectivity.online, local) { c, online, l ->
CardConnectionUi(c.provider, c.connection?.takeIf { it.id == id }, online, l.dialog, l.erase, l.busy, l.couldNotReach)
}.stateIn(viewModelScope, SharingStarted.Eagerly, CardConnectionUi())
/** A shorter history only (E-41); a longer one is a renewal through S-25 (CP-3). */
fun setHistory(days: Int) {
val current = ui.value.connection ?: return
if (days >= current.historyDays) return
run { cards.setHistoryDays(id, days) }
}
fun askDisconnect() = local.update { it.copy(dialog = true, erase = false) }
fun dismissDisconnect() = local.update { it.copy(dialog = false) }
fun setEraseHandled(erase: Boolean) = local.update { it.copy(erase = erase) }
fun confirmDisconnect() {
val erase = local.value.erase
local.update { it.copy(dialog = false) }
run(onOk = { events.send(ConnectionEvent.Disconnected) }) { cards.disconnect(id, erase) }
}
fun messageShown() = local.update { it.copy(couldNotReach = false) }
private fun run(onOk: suspend () -> Unit = {}, call: suspend () -> OnlineResult<Unit>) {
local.update { it.copy(busy = true, couldNotReach = false) }
viewModelScope.launch {
val result = call()
local.update { it.copy(busy = false, couldNotReach = result is OnlineResult.Failed && result.code == NETWORK) }
if (result is OnlineResult.Ok) onOk()
}
}
companion object {
const val ARG_CONNECTION_ID = "connectionId"
private const val NETWORK = "network"
}
}
/** Navigation and actions of S-26. */
data class CardConnectionActions(
val onBack: () -> Unit = {},
val onRenew: (String) -> Unit = {},
val onHistory: (Int) -> Unit = {},
val onAskDisconnect: () -> Unit = {},
val onDismissDisconnect: () -> Unit = {},
val onEraseHandled: (Boolean) -> Unit = {},
val onConfirmDisconnect: () -> Unit = {},
)
/** S-26 Card connection (pushed from S-24 or S-23's header). [onDisconnected] leaves the screen after "Koppla från". */
@Composable
fun CardConnectionScreen(
onBack: () -> Unit,
onRenew: (String) -> Unit,
onDisconnected: () -> Unit,
modifier: Modifier = Modifier,
viewModel: CardConnectionViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
val resources = LocalResources.current
LaunchedEffect(viewModel) { viewModel.eventFlow.collect { if (it == ConnectionEvent.Disconnected) onDisconnected() } }
LaunchedEffect(ui.couldNotReach) {
if (!ui.couldNotReach) return@LaunchedEffect
viewModel.messageShown()
snackbar.showKvittSnackbar(resources, DesignR.string.common_could_not_reach_server)
}
val actions = CardConnectionActions(
onBack = onBack,
onRenew = onRenew,
onHistory = viewModel::setHistory,
onAskDisconnect = viewModel::askDisconnect,
onDismissDisconnect = viewModel::dismissDisconnect,
onEraseHandled = viewModel::setEraseHandled,
onConfirmDisconnect = viewModel::confirmDisconnect,
)
CardConnectionContent(ui, actions, modifier, snackbar)
if (ui.disconnectDialog) {
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(R.string.cards_disconnect_title, ui.provider?.name.orEmpty()),
body = stringResource(R.string.cards_disconnect_body),
confirm = stringResource(R.string.cards_disconnect),
dismiss = stringResource(DesignR.string.common_cancel),
onConfirm = actions.onConfirmDisconnect,
onDismiss = actions.onDismissDisconnect,
destructive = true,
extra = { EraseAll(ui.eraseHandled, actions.onEraseHandled) },
)
}
}
/** "Radera även kopplade och ignorerade köp" (CP-12), the checkbox of the disconnect dialog. */
@Composable
fun EraseAll(checked: Boolean, onChange: (Boolean) -> Unit) {
Row(
Modifier.fillMaxWidth().toggleable(checked, role = Role.Checkbox, onValueChange = onChange),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
) {
Checkbox(checked, onCheckedChange = null)
Text(stringResource(R.string.cards_disconnect_erase_all), style = MaterialTheme.typography.bodyMedium)
}
}
/** The stateless S-26 (without its dialog). */
@Composable
fun CardConnectionContent(ui: CardConnectionUi, actions: CardConnectionActions, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null) {
val provider = ui.provider
KvittScaffold(
modifier,
topBar = { KvittTopAppBar(provider?.name.orEmpty(), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
snackbarHostState = snackbarHostState,
) { padding ->
val connection = ui.connection ?: return@KvittScaffold
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
CardArt(provider?.imageKey ?: connection.providerId, provider?.name.orEmpty(), Modifier.width(KvittDimens.cardArtLarge))
}
val expired = connection.status == CardConnectionStatus.EXPIRED
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
KvittIconImage(
if (expired) KvittIcon.Error else KvittIcon.CheckCircle,
null,
filled = true,
tint = if (expired) MaterialTheme.colorScheme.error else KvittTheme.colors.owed,
)
Text(stringResource(if (expired) R.string.cards_status_expired else R.string.cards_status_active), style = MaterialTheme.typography.titleMedium)
}
connection.validUntil?.let { Text(stringResource(R.string.cards_valid_until, instantDetailDate(it)), style = MaterialTheme.typography.bodyMedium) }
connection.lastFetchAt?.let {
Text(stringResource(R.string.cards_last_updated, relativeTime(it)), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
History(ui, connection, actions)
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
KvittButton(
stringResource(R.string.cards_renew),
{ actions.onRenew(connection.providerId) },
Modifier.fillMaxWidth(),
kind = if (connection.expiresSoon || expired) ButtonKind.Filled else ButtonKind.Tonal,
icon = KvittIcon.Key,
enabled = ui.canAct,
)
KvittButton(stringResource(R.string.cards_disconnect), actions.onAskDisconnect, Modifier.fillMaxWidth(), kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = ui.canAct)
if (!ui.online) NeedsConnectionHint()
}
}
}
}
/** "Historik vid anslutning": shorter values selectable (E-41), a longer one only through "Förnya med BankID" (CP-3). */
@Composable
private fun History(ui: CardConnectionUi, connection: CardConnection, actions: CardConnectionActions) {
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
val label = stringResource(R.string.cards_history_setting)
Text(label, style = MaterialTheme.typography.titleSmall)
val options = HISTORY_OPTIONS.filter { it <= connection.historyDays }.ifEmpty { listOf(connection.historyDays) }
HistoryChips(options, connection.historyDays, actions.onHistory, label, enabled = ui.canAct)
Text(stringResource(R.string.cards_history_longer_note), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -0,0 +1,236 @@
package se.hagstedt.kvitt.feature.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.BadgeKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.MoneyText
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.listDate
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.spokenMoney
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.CardDirection
import se.hagstedt.kvitt.model.CardPurchase
import se.hagstedt.kvitt.model.CardPurchaseRow
import se.hagstedt.kvitt.model.CardPurchaseStatus
import se.hagstedt.kvitt.model.ExpenseMatch
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
// Shared pieces of S-23 to S-27: amounts, dates, the purchase row and the expense match row.
/** "24,99 EUR" / "24.99 EUR": a foreign amount as the bank's decimal string in the app's number form (CP-7). */
@Composable
@ReadOnlyComposable
fun foreignAmount(purchase: CardPurchase): String {
val amount = purchase.originalAmount.orEmpty().let { if (kvittLanguage() == KvittLanguage.SWEDISH) it.replace('.', ',') else it }
return stringResource(R.string.cards_foreign_amount, amount, purchase.originalCurrency.orEmpty())
}
/** The list date of an RFC 3339 instant in the phone's zone: "22 apr. 2027" (§9.8). */
@Composable
@ReadOnlyComposable
fun instantListDate(rfc3339: String): String {
val clock = LocalKvittClock.current
return DateFormatter.listDate(Instant.parse(rfc3339).toLocalDateTime(clock.zone).date, clock.today, kvittLanguage())
}
/** The detail date of an RFC 3339 instant in the phone's zone: "torsdag 22 april 2027" (§9.8). */
@Composable
@ReadOnlyComposable
fun instantDetailDate(rfc3339: String, zone: TimeZone = LocalKvittClock.current.zone): String =
DateFormatter.detailDate(Instant.parse(rfc3339).toLocalDateTime(zone).date, kvittLanguage())
/** A day header of S-23's list: the §9.8 list date (title-small, heading), as the ledger's month header. */
@Composable
fun CardDayHeader(date: String, modifier: Modifier = Modifier) {
Text(
listDate(date),
modifier
.fillMaxWidth()
.padding(start = KvittDimens.monthHeaderSide, end = KvittDimens.monthHeaderSide, top = KvittDimens.monthHeaderTop, bottom = KvittDimens.monthHeaderBottom)
.semantics { heading() },
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
/**
* A purchase as a segmented-group item (S-23, S-27): merchant (one line, ellipsis), its status badges ("Preliminär",
* "Insättning", "Utländsk valuta"), the linked line under "Kopplade" and the match chip under "Att hantera" (CP-10);
* the amount on the right, under the text at ≥ 1.5× font scale. One node for TalkBack, the chip a separate button.
* [onMatch] links the single match in one tap; [onMatches] opens the sheet for two or three. Both null: no chip (S-27).
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CardPurchaseItem(
item: SegmentedItemScope,
row: CardPurchaseRow,
onClick: () -> Unit,
modifier: Modifier = Modifier,
showCredit: Boolean = true,
onMatch: ((ExpenseMatch) -> Unit)? = null,
onMatches: (() -> Unit)? = null,
) {
val p = row.purchase
val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE
val separator = stringResource(DesignR.string.common_separator_comma)
val amount = p.amountOre?.let { spokenMoney(it) } ?: foreignAmount(p)
val badges = buildList {
if (p.status == CardPurchaseStatus.PENDING) add(stringResource(R.string.cards_pending) to BadgeKind.Pending)
if (p.direction == CardDirection.CREDIT && showCredit) add(stringResource(R.string.cards_credit) to BadgeKind.Neutral)
if (p.amountOre == null) add(stringResource(R.string.cards_foreign) to BadgeKind.Neutral)
}
val linkedLine = p.linked?.let { stringResource(R.string.cards_linked_to, it.description, it.accountName) }
val spoken = (listOf(p.merchant, amount, listDate(p.date)) + badges.map { it.first } + listOfNotNull(linkedLine)).joinToString(separator)
Column(
modifier
.fillMaxWidth()
.clip(item.shape)
.background(item.fill)
.heightIn(min = KvittTheme.spacing.ledgerRowMin)
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.CenterVertically),
) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.clearAndSetSemantics { contentDescription = spoken; role = Role.Button },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(p.merchant, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (badges.isNotEmpty()) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
badges.forEach { (text, kind) -> KvittBadge(text, kind = kind) }
}
}
if (linkedLine != null) {
Text(linkedLine, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
if (large) PurchaseAmount(p)
}
if (!large) PurchaseAmount(p)
}
val matches = row.matches
when {
matches.size == 1 && onMatch != null -> KvittButton(
stringResource(R.string.cards_match_one, matches.single().description),
{ onMatch(matches.single()) },
kind = ButtonKind.Tonal,
icon = KvittIcon.Link,
)
matches.size > 1 && onMatches != null -> KvittButton(
pluralStringResource(R.plurals.cards_match_many, matches.size, matches.size),
onMatches,
kind = ButtonKind.Outlined,
icon = KvittIcon.Link,
)
}
}
}
@Composable
private fun PurchaseAmount(p: CardPurchase) {
if (p.amountOre != null) {
MoneyText(p.amountOre ?: 0L, AmountStyle.Small, if (p.direction == CardDirection.CREDIT) AmountTone.Muted else AmountTone.Neutral)
} else {
Text(foreignAmount(p), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
}
}
/**
* An existing expense to link (the sheet's matches and "Välj en annan utgift", CP-10): description, then account,
* payer and date, the amount on the right. One button node; a tap links.
*/
@Composable
fun ExpenseMatchItem(item: SegmentedItemScope, match: ExpenseMatch, onClick: () -> Unit, modifier: Modifier = Modifier) {
val dot = stringResource(DesignR.string.common_separator_dot)
val separator = stringResource(DesignR.string.common_separator_comma)
val details = listOf(match.accountName, personName(match.payer), listDate(match.date)).joinToString(dot)
val spoken = listOf(match.description, match.accountName, personName(match.payer), listDate(match.date), spokenMoney(match.amountOre)).joinToString(separator)
Row(
modifier
.fillMaxWidth()
.clip(item.shape)
.background(item.fill)
.clickable(onClick = onClick)
.clearAndSetSemantics { contentDescription = spoken; role = Role.Button }
.heightIn(min = KvittTheme.spacing.rowMin)
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
Column(Modifier.weight(1f)) {
Text(match.description, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(details, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
MoneyText(match.amountOre, AmountStyle.Small, AmountTone.Neutral)
}
}
/** The history choice of S-25 and S-26 (CP-3) as wrapping chips, so "180 dagar" is never cut at large font scales. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun HistoryChips(options: List<Int>, selected: Int, onSelect: (Int) -> Unit, label: String, modifier: Modifier = Modifier, enabled: Boolean = true) {
FlowRow(
modifier.fillMaxWidth().semantics { contentDescription = label },
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
) {
options.forEach { days ->
FilterChip(
selected = days == selected,
onClick = { onSelect(days) },
label = { Text(pluralStringResource(R.plurals.cards_history_days, days, days)) },
enabled = enabled,
leadingIcon = if (days == selected) {
{ KvittIconImage(KvittIcon.Check, null, size = KvittDimens.iconChip) }
} else {
null
},
)
}
}
}
@@ -0,0 +1,117 @@
package se.hagstedt.kvitt.feature.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.CardPurchase
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CardPurchaseRow
import se.hagstedt.kvitt.model.CardRepository
import javax.inject.Inject
/** What S-27 shows: the account's name and the viewer's purchases to handle; [purchases] null before the first read. */
data class CardPickUi(val accountId: AccountId? = null, val accountName: String = "", val purchases: List<CardPurchase>? = null)
/** S-27 state holder (AC-22.9): local only, works offline. Argument: [ARG_ACCOUNT_ID]. */
@HiltViewModel
class CardPickViewModel @Inject constructor(savedStateHandle: SavedStateHandle, cards: CardRepository, accounts: AccountRepository) : ViewModel() {
private val accountId = savedStateHandle.get<String>(ARG_ACCOUNT_ID)?.let(::AccountId)
val ui: StateFlow<CardPickUi> = combine(cards.toHandle(), accounts.all()) { purchases, all ->
CardPickUi(accountId, all.firstOrNull { it.id == accountId }?.name.orEmpty(), purchases)
}.stateIn(viewModelScope, SharingStarted.Eagerly, CardPickUi(accountId))
companion object {
const val ARG_ACCOUNT_ID = "accountId"
}
}
/**
* S-27 Från kortköp (pushed from S-5): a tap opens S-7 prefilled with this account ([onPick], CP-9). It hosts `:app`'s
* [snackbarHostState], so "Utgiften sparades" shows here when S-7 returns (D-071).
*/
@Composable
fun CardPickScreen(
onBack: () -> Unit,
onPick: (CardPurchaseId, AccountId?) -> Unit,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
viewModel: CardPickViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
CardPickContent(ui, onBack, { onPick(it, ui.accountId) }, modifier, snackbarHostState)
}
/** The stateless S-27: S-23's rows without chips, newest first. */
@Composable
fun CardPickContent(ui: CardPickUi, onBack: () -> Unit, onPick: (CardPurchaseId) -> Unit, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null) {
KvittScaffold(
modifier,
snackbarHostState = snackbarHostState,
topBar = { KvittTopAppBar(stringResource(R.string.cards_pick_title), navigation = TopBarNavigation.Back, onNavigation = onBack, subtitle = ui.accountName.takeIf { it.isNotEmpty() }) },
) { padding ->
val purchases = ui.purchases
when {
purchases == null -> Box(Modifier.fillMaxSize().padding(padding))
purchases.isEmpty() -> Column(Modifier.fillMaxSize().padding(padding).padding(KvittTheme.spacing.gutter)) {
Column(
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card).padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(stringResource(R.string.cards_pick_empty), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
}
}
else -> LazyColumn(
Modifier.fillMaxSize().padding(padding),
contentPadding = PaddingValues(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, top = KvittTheme.spacing.s1, bottom = KvittTheme.spacing.s6),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
) {
purchases.groupBy { it.date }.forEach { (date, inDay) ->
item(key = date) {
Column {
CardDayHeader(date)
SegmentedGroup(inDay.size) { item ->
val p = inDay[item.index]
CardPurchaseItem(item, CardPurchaseRow(p, emptyList()), onClick = { onPick(p.id) })
}
}
}
}
}
}
}
}
@@ -0,0 +1,378 @@
package se.hagstedt.kvitt.feature.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittDestination
import se.hagstedt.kvitt.design.component.KvittNavigationBar
import se.hagstedt.kvitt.design.component.KvittPullToRefresh
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.relativeTime
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardProvider
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CardsState
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseMatch
import se.hagstedt.kvitt.core.design.R as DesignR
/** Navigation and actions of S-23; `:app` wires the navigation lambdas (D-017), the ViewModel the rest. */
data class CardsActions(
val onNavigate: (KvittDestination) -> Unit = {},
val onConnect: () -> Unit = {},
val onOpenConnection: (CardConnectionId) -> Unit = {},
val onRenew: (String) -> Unit = {},
val onCreateExpense: (CardPurchaseId) -> Unit = {},
val onOpenExpense: (ExpenseId) -> Unit = {},
val onFilter: (CardFilter) -> Unit = {},
val onRefresh: () -> Unit = {},
val onPullToRefresh: () -> Unit = {},
val onOpenPurchase: (CardPurchaseId) -> Unit = {},
val onOpenOthers: () -> Unit = {},
val onDismissSheet: () -> Unit = {},
val onLink: (CardPurchaseId, ExpenseId) -> Unit = { _, _ -> },
val onIgnore: (CardPurchaseId) -> Unit = {},
val onUnignore: (CardPurchaseId) -> Unit = {},
val onUnlink: (CardPurchaseId) -> Unit = {},
)
/**
* S-23 Kortköp (top level, D-172): the holder's purchases by day with the filters, the purchase sheet and its second
* sheet "Välj en annan utgift". [snackbarHostState] is `:app`'s, so S-7's "Utgiften sparades" shows here (D-071).
*/
@Composable
fun CardsScreen(
onNavigate: (KvittDestination) -> Unit,
onConnect: () -> Unit,
onOpenConnection: (CardConnectionId) -> Unit,
onRenew: (String) -> Unit,
onCreateExpense: (CardPurchaseId) -> Unit,
onOpenExpense: (ExpenseId) -> Unit,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
viewModel: CardsViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
val resources = LocalResources.current
// "Uppdaterad <relative time>" is refreshed every minute, as the sync line (design-spec §3.6).
var minute by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
while (true) {
delay(MINUTE_MILLIS)
minute += 1
}
}
LaunchedEffect(ui.message) {
val message = ui.message ?: return@LaunchedEffect
viewModel.messageShown()
val (text, undo) = when (message) {
is CardsMessage.Linked -> R.string.cards_snackbar_linked to true
is CardsMessage.Ignored -> R.string.cards_snackbar_ignored to true
CardsMessage.CouldNotReach -> DesignR.string.common_could_not_reach_server to false
}
val result = snackbarHostState.showKvittSnackbar(resources, text, action = if (undo) DesignR.string.common_undo else null)
if (result == SnackbarResult.ActionPerformed) viewModel.undo(message)
}
val actions = CardsActions(
onNavigate = onNavigate,
onConnect = onConnect,
onOpenConnection = onOpenConnection,
onRenew = onRenew,
onCreateExpense = { id ->
viewModel.closeSheet()
onCreateExpense(id)
},
onOpenExpense = { id ->
viewModel.closeSheet()
onOpenExpense(id)
},
onFilter = viewModel::setFilter,
onRefresh = viewModel::refresh,
onPullToRefresh = viewModel::pullToRefresh,
onOpenPurchase = viewModel::openPurchase,
onOpenOthers = viewModel::openOthers,
onDismissSheet = viewModel::dismissSheet,
onLink = viewModel::link,
onIgnore = viewModel::ignore,
onUnignore = viewModel::unignore,
onUnlink = viewModel::unlink,
)
CardsContent(ui, actions, modifier, snackbarHostState, minute)
}
private const val MINUTE_MILLIS = 60_000L
/** The stateless S-23 with its sheets, rendered by [CardsScreen], previews and screenshot tests. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CardsContent(ui: CardsUi, actions: CardsActions, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null, tick: Int = 0) {
KvittScaffold(
modifier = modifier,
topBar = { KvittTopAppBar(stringResource(R.string.cards_title)) },
bottomBar = { KvittNavigationBar(KvittDestination.Cards, actions.onNavigate) },
snackbarHostState = snackbarHostState,
) { padding ->
val cards = ui.cards
when {
cards == null -> Box(Modifier.fillMaxSize().padding(padding))
cards.connection == null -> NoConnection(cards.provider, actions, Modifier.padding(padding))
else -> KvittPullToRefresh(ui.refreshing, actions.onPullToRefresh, Modifier.fillMaxSize().padding(padding)) {
CardsList(ui, cards, actions, tick)
}
}
val detail = ui.detail
if (ui.sheet != null && detail != null) {
ModalBottomSheet(actions.onDismissSheet, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) {
if (ui.sheet == CardsSheet.Others) OthersSheet(detail.row.purchase.id, detail.others, actions) else PurchaseSheet(detail, actions)
}
}
}
}
/** No connection (D-172: both users always see the tab): large artwork, "Anslut ditt kort", "Anslut kort" → S-24. */
@Composable
private fun NoConnection(provider: CardProvider?, actions: CardsActions, modifier: Modifier) {
Column(
modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
) {
Column(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card)
.padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
) {
CardArt(provider?.imageKey ?: SJ_PRIO_MASTERCARD, provider?.name.orEmpty(), Modifier.width(KvittDimens.cardArtLarge))
Text(stringResource(R.string.cards_empty_title), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Text(stringResource(R.string.cards_empty_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
KvittButton(stringResource(R.string.cards_connect), actions.onConnect, size = ButtonSize.Medium, icon = KvittIcon.CreditCard)
}
}
}
@Composable
private fun CardsList(ui: CardsUi, cards: CardsState, actions: CardsActions, tick: Int) {
val spacing = KvittTheme.spacing
LazyColumn(
Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = spacing.gutter, end = spacing.gutter, top = spacing.s1, bottom = spacing.s6),
verticalArrangement = Arrangement.spacedBy(spacing.sectionGap),
) {
item(key = "banner") { StatusBanner(ui, cards, actions) }
item(key = "header") { Header(ui, cards, actions, tick) }
if (ui.firstFetch) {
item(key = "first") { FirstFetch() }
return@LazyColumn
}
item(key = "filters") { Filters(cards, actions) }
if (cards.days.isEmpty()) {
item(key = "empty") { EmptyFilter(cards.filter) }
}
cards.days.forEach { day ->
item(key = "day-${day.date}") {
Column {
CardDayHeader(day.date)
SegmentedGroup(day.rows.size) { item ->
val row = day.rows[item.index]
val id = row.purchase.id
CardPurchaseItem(
item,
row,
onClick = { actions.onOpenPurchase(id) },
onMatch = { match -> actions.onLink(id, match.id) },
onMatches = { actions.onOpenPurchase(id) },
)
}
}
}
}
}
}
/** Expired (CP-6) wins over expiring and fetch failed (CP-14); at most one banner. */
@Composable
private fun StatusBanner(ui: CardsUi, cards: CardsState, actions: CardsActions) {
val connection = cards.connection ?: return
val provider = cards.provider?.id ?: connection.providerId
when {
ui.expired -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(R.string.cards_expired)) { _ ->
KvittButton(stringResource(R.string.cards_renew), { actions.onRenew(provider) }, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Key, enabled = ui.online)
}
ui.expiring -> Banner(BannerKind.Warning, KvittIcon.Warning, stringResource(R.string.cards_expiring, connection.validUntil?.let { instantListDate(it) }.orEmpty())) { content ->
KvittButton(stringResource(R.string.cards_renew), { actions.onRenew(provider) }, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Key, enabled = ui.online)
}
ui.fetchFailed -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(R.string.cards_fetch_failed, connection.lastFetchAt?.let { relativeTime(it) }.orEmpty())) { content ->
KvittButton(stringResource(DesignR.string.common_retry), actions.onRefresh, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Refresh, enabled = ui.canRefresh)
}
}
}
/** The header card: small artwork, name, "Bara du ser dina kortköp", "Uppdaterad …" and "Uppdatera"; a tap opens S-26. */
@Composable
private fun Header(ui: CardsUi, cards: CardsState, actions: CardsActions, tick: Int) {
val connection = cards.connection ?: return
val provider = cards.provider
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
Row(
Modifier
.fillMaxWidth()
.clickable(role = Role.Button, onClickLabel = stringResource(R.string.cards_connection_settings)) { actions.onOpenConnection(connection.id) }
.semantics(mergeDescendants = true) {},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap),
) {
CardArt(provider?.imageKey ?: connection.providerId, provider?.name.orEmpty(), Modifier.width(KvittDimens.cardArtHeader))
Column(Modifier.weight(1f)) {
Text(provider?.name.orEmpty(), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.cards_private_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
connection.lastFetchAt?.let { at ->
key(tick) {
Text(stringResource(R.string.cards_updated, relativeTime(at)), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
Column {
val fetching = cards.fetching
KvittButton(
stringResource(if (fetching) R.string.cards_refreshing else R.string.cards_refresh),
actions.onRefresh,
kind = ButtonKind.Tonal,
icon = KvittIcon.Refresh,
enabled = ui.online && !ui.expired,
loading = fetching,
)
if (!ui.online) NeedsConnectionHint()
}
}
}
@Composable
private fun FirstFetch() {
Column(Modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s8), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s4)) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Text(stringResource(R.string.cards_first_fetch), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
}
}
/** "Att hantera" (default), "Kopplade", "Ignorerade", "Alla", each with its count. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Filters(cards: CardsState, actions: CardsActions) {
// The four chips wrap rather than scroll, so the selected one is always in view (Swedish labels do not fit one row).
FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
CardFilter.entries.forEach { filter ->
FilterChip(
selected = cards.filter == filter,
onClick = { actions.onFilter(filter) },
label = {
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(stringResource(filterLabel(filter)))
Text((cards.counts[filter] ?: 0).toString(), fontWeight = FontWeight.Bold)
}
},
)
}
}
}
fun filterLabel(filter: CardFilter): Int = when (filter) {
CardFilter.TO_HANDLE -> R.string.cards_filter_to_handle
CardFilter.LINKED -> R.string.cards_filter_linked
CardFilter.IGNORED -> R.string.cards_filter_ignored
CardFilter.ALL -> R.string.cards_filter_all
}
@Composable
private fun EmptyFilter(filter: CardFilter) {
Column(
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card).padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap),
) {
if (filter == CardFilter.TO_HANDLE) {
Text(stringResource(R.string.cards_nothing_to_handle), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Text(stringResource(R.string.cards_nothing_to_handle_body), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center)
} else {
Text(stringResource(R.string.cards_filter_empty), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
}
}
}
/** "Välj en annan utgift" (CP-10): any amount within 14 days, newest first; one tap links. */
@Composable
fun OthersSheet(purchase: CardPurchaseId, others: List<ExpenseMatch>, actions: CardsActions) {
SheetColumn {
Text(stringResource(R.string.cards_sheet_other), style = MaterialTheme.typography.titleLarge)
if (others.isEmpty()) {
Text(stringResource(R.string.cards_sheet_no_matches), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
SegmentedGroup(others.size) { item -> ExpenseMatchItem(item, others[item.index], { actions.onLink(purchase, others[item.index].id) }) }
}
}
}
@Composable
internal fun SheetColumn(content: @Composable ColumnScope.() -> Unit) {
Column(
Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s6),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
content = content,
)
}
@@ -0,0 +1,176 @@
package se.hagstedt.kvitt.feature.cards
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.CardConnectionStatus
import se.hagstedt.kvitt.model.CardFetchError
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardPurchaseDetail
import se.hagstedt.kvitt.model.CardPurchaseId
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.CardsState
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.OnlineResult
import javax.inject.Inject
/** A snackbar of S-23. [Linked] and [Ignored] carry "Ångra" (undo of that purchase's change). */
sealed interface CardsMessage {
data class Linked(val purchase: CardPurchaseId) : CardsMessage
data class Ignored(val purchase: CardPurchaseId) : CardsMessage
/** "Kunde inte nå servern" (a failed "Uppdatera", §9.9). */
data object CouldNotReach : CardsMessage
}
/** Which sheet of S-23 is open: the purchase sheet, or its second sheet "Välj en annan utgift" (CP-10). */
enum class CardsSheet { Purchase, Others }
/**
* What S-23 shows. [cards] null before the first emission of the local copy. [detail] the open purchase's sheet;
* [refreshing] a pull to refresh is running (the indicator stays until it ends, D-127).
*/
data class CardsUi(
val cards: CardsState? = null,
val online: Boolean = true,
val sheet: CardsSheet? = null,
val detail: CardPurchaseDetail? = null,
val refreshing: Boolean = false,
val message: CardsMessage? = null,
) {
val connection get() = cards?.connection
/** The consent has ended (CP-6): status `expired`, or the last fetch said so. */
val expired: Boolean get() = connection?.let { it.status == CardConnectionStatus.EXPIRED || it.lastFetchError == CardFetchError.CONSENT_EXPIRED } == true
/** Connected and no fetch has finished yet: "Hämtar dina köp…" (S-23 first fetch). */
val firstFetch: Boolean get() = connection?.let { it.status == CardConnectionStatus.ACTIVE && it.lastFetchAt == null && it.lastFetchError == null } == true
/** CP-14: a failed fetch other than an ended consent shows the banner with "Försök igen". */
val fetchFailed: Boolean get() = !expired && connection?.lastFetchError != null
/** CP-6: from 14 days before `valid_until`. */
val expiring: Boolean get() = !expired && connection?.expiresSoon == true
/** "Uppdatera" needs the server and an active consent (§9.9, AC-22.11). */
val canRefresh: Boolean get() = online && !expired && cards?.fetching != true
}
private data class CardsLocal(
val filter: CardFilter = CardFilter.TO_HANDLE,
val sheet: CardsSheet? = null,
val purchase: CardPurchaseId? = null,
val refreshing: Boolean = false,
val message: CardsMessage? = null,
)
/**
* S-23 state holder (SPEC §13.3 S-23, §9.12). Reads are Room's and work offline; linking and ignoring are local first
* (CP-9).
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class CardsViewModel @Inject constructor(
private val cards: CardRepository,
private val connectivity: ConnectivityRepository,
) : ViewModel() {
private val local = MutableStateFlow(CardsLocal())
private val state = local.map { it.filter }.let { filters -> filters.flatMapLatest { cards.cards(it) } }
private val detail = local.map { it.purchase }.flatMapLatest { id -> id?.let { cards.purchase(it) } ?: flowOf(null) }
val ui: StateFlow<CardsUi> = combine(state, detail, connectivity.online, local) { s, d, online, l ->
CardsUi(cards = s, online = online, sheet = l.sheet.takeIf { d != null }, detail = d, refreshing = l.refreshing, message = l.message)
}.stateIn(viewModelScope, SharingStarted.Eagerly, CardsUi())
fun setFilter(filter: CardFilter) = local.update { it.copy(filter = filter) }
/** Pull to refresh (CP-5): E-42 and a card pull; offline it ends at once (§9.9). */
fun pullToRefresh() {
if (local.value.refreshing) return
local.update { it.copy(refreshing = true) }
viewModelScope.launch {
try {
if (connectivity.online.first()) cards.refresh()
} finally {
local.update { it.copy(refreshing = false) }
}
}
}
/** "Uppdatera" and the fetch-failed banner's "Försök igen". */
fun refresh() {
viewModelScope.launch {
when (val result = cards.refresh()) {
is OnlineResult.Failed -> if (result.code == NETWORK) local.update { it.copy(message = CardsMessage.CouldNotReach) }
OnlineResult.Offline, is OnlineResult.Ok -> Unit
}
}
}
fun openPurchase(id: CardPurchaseId) = local.update { it.copy(purchase = id, sheet = CardsSheet.Purchase) }
fun openOthers() = local.update { it.copy(sheet = CardsSheet.Others) }
/** Back from "Välj en annan utgift" returns to the purchase sheet; from the purchase sheet it closes. */
fun dismissSheet() = local.update { if (it.sheet == CardsSheet.Others) it.copy(sheet = CardsSheet.Purchase) else it.copy(sheet = null, purchase = null) }
fun closeSheet() = local.update { it.copy(sheet = null, purchase = null) }
/** One tap links (the row's chip or a match in the sheet), closes the sheet and shows "Kopplad" with "Ångra" (CP-9). */
fun link(purchase: CardPurchaseId, expense: ExpenseId) {
closeSheet()
viewModelScope.launch {
cards.link(purchase, expense)
local.update { it.copy(message = CardsMessage.Linked(purchase)) }
}
}
fun ignore(purchase: CardPurchaseId) {
closeSheet()
viewModelScope.launch {
cards.setIgnored(purchase, true)
local.update { it.copy(message = CardsMessage.Ignored(purchase)) }
}
}
fun unignore(purchase: CardPurchaseId) {
closeSheet()
viewModelScope.launch { cards.setIgnored(purchase, false) }
}
fun unlink(purchase: CardPurchaseId) {
closeSheet()
viewModelScope.launch { cards.unlink(purchase) }
}
/** "Ångra" on a snackbar. */
fun undo(message: CardsMessage) {
viewModelScope.launch {
when (message) {
is CardsMessage.Linked -> cards.unlink(message.purchase)
is CardsMessage.Ignored -> cards.setIgnored(message.purchase, false)
CardsMessage.CouldNotReach -> Unit
}
}
}
fun messageShown() = local.update { it.copy(message = null) }
private companion object {
const val NETWORK = "network"
}
}
@@ -0,0 +1,169 @@
package se.hagstedt.kvitt.feature.cards
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionBanner
import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.CardConnection
import se.hagstedt.kvitt.model.CardConnectionId
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardProvider
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import javax.inject.Inject
import se.hagstedt.kvitt.core.design.R as DesignR
/** What E-36 gave S-24. */
sealed interface CatalogueLoad {
data object Loading : CatalogueLoad
data class Loaded(val providers: List<CardProvider>) : CatalogueLoad
/** Offline when S-24 opened: the catalogue comes from the server (§9.9); [known] the cached entry, drawn disabled. */
data class Offline(val known: CardProvider?) : CatalogueLoad
data object Failed : CatalogueLoad
}
/** S-24: the catalogue and which provider the viewer has connected (ACTIVE or EXPIRED). */
data class ConnectCardUi(val load: CatalogueLoad = CatalogueLoad.Loading, val connection: CardConnection? = null, val online: Boolean = true)
/** S-24 state holder: E-36 when the screen opens, again on "Försök igen" or when the phone comes online. */
@HiltViewModel
class ConnectCardViewModel @Inject constructor(private val cards: CardRepository, private val connectivity: ConnectivityRepository) : ViewModel() {
private val load = MutableStateFlow<CatalogueLoad>(CatalogueLoad.Loading)
val ui: StateFlow<ConnectCardUi> = combine(load, cards.cards(CardFilter.ALL), connectivity.online) { l, c, online ->
ConnectCardUi(l, c.connection, online)
}.stateIn(viewModelScope, SharingStarted.Eagerly, ConnectCardUi())
init {
retry()
viewModelScope.launch {
connectivity.online.collect { online -> if (online && load.value is CatalogueLoad.Offline) retry() }
}
}
fun retry() {
load.value = CatalogueLoad.Loading
viewModelScope.launch {
load.value = when (val result = cards.providers()) {
is OnlineResult.Ok -> CatalogueLoad.Loaded(result.value)
OnlineResult.Offline -> CatalogueLoad.Offline(cards.cards(CardFilter.ALL).first().provider)
is OnlineResult.Failed -> CatalogueLoad.Failed
}
}
}
}
/** S-24 Anslut kort (pushed from S-13): one tile per catalogue entry; a connected card opens S-26, another S-25. */
@Composable
fun ConnectCardScreen(
onBack: () -> Unit,
onOpenProvider: (String) -> Unit,
onOpenConnection: (CardConnectionId) -> Unit,
modifier: Modifier = Modifier,
viewModel: ConnectCardViewModel = hiltViewModel(),
) {
val ui by viewModel.ui.collectAsStateWithLifecycle()
ConnectCardContent(ui, onBack, onOpenProvider, onOpenConnection, viewModel::retry, modifier)
}
@Composable
fun ConnectCardContent(
ui: ConnectCardUi,
onBack: () -> Unit,
onOpenProvider: (String) -> Unit,
onOpenConnection: (CardConnectionId) -> Unit,
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
KvittScaffold(modifier, topBar = { KvittTopAppBar(stringResource(R.string.cards_connect_title), navigation = TopBarNavigation.Back, onNavigation = onBack) }) { padding ->
Column(
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
) {
Text(stringResource(R.string.cards_connect_intro), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
when (val load = ui.load) {
CatalogueLoad.Loading -> KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
Skeleton(KvittDimens.cardArtLarge, KvittDimens.cardArtLarge / KvittDimens.CARD_ART_ASPECT, Modifier.fillMaxWidth())
Skeleton(KvittDimens.skeletonPillWidth, KvittTheme.spacing.s5)
}
is CatalogueLoad.Loaded -> load.providers.forEach { provider ->
val connection = ui.connection?.takeIf { it.providerId == provider.id }
ProviderTile(provider, connection, enabled = ui.online && (connection != null || provider.available)) {
if (connection != null) onOpenConnection(connection.id) else onOpenProvider(provider.id)
}
}
is CatalogueLoad.Offline -> {
NeedsConnectionBanner(stringResource(R.string.cards_connect_offline_body))
load.known?.let { ProviderTile(it, ui.connection?.takeIf { c -> c.providerId == it.id }, enabled = false) {} }
}
CatalogueLoad.Failed -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(DesignR.string.common_could_not_reach_server)) { content ->
KvittButton(stringResource(DesignR.string.common_retry), onRetry, kind = ButtonKind.Text, contentColor = content, icon = KvittIcon.Refresh)
}
}
}
}
}
/** A catalogue tile: artwork (full width, card aspect), name, issuer, and its connected or unavailable line. */
@Composable
private fun ProviderTile(provider: CardProvider, connection: CardConnection?, enabled: Boolean, onClick: () -> Unit) {
val line = when {
connection != null -> connection.validUntil?.let { stringResource(R.string.cards_provider_connected, instantListDate(it)) } ?: stringResource(R.string.cards_status_active)
!provider.available -> stringResource(R.string.cards_provider_unavailable)
else -> null
}
KvittCard(
Modifier
.fillMaxWidth()
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
.semantics(mergeDescendants = true) {},
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
CardArt(provider.imageKey, provider.name, Modifier.fillMaxWidth().alpha(if (enabled) 1f else KvittDimens.DISABLED_CONTENT_ALPHA * 2))
Column {
Text(provider.name, style = MaterialTheme.typography.titleMedium)
Text(provider.issuer, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (line != null) Text(line, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -0,0 +1,89 @@
package se.hagstedt.kvitt.feature.cards
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.BadgeKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.MoneyText
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.detailDate
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.model.CardDirection
import se.hagstedt.kvitt.model.CardPurchase
import se.hagstedt.kvitt.model.CardPurchaseDetail
import se.hagstedt.kvitt.model.CardPurchaseStatus
/**
* The purchase sheet of S-23 (§13.3): merchant, the purchase date in the detail form, amount and status. To handle:
* "Skapa utgift" (primary, S-7 prefilled, CP-9), "Koppla till befintlig utgift" with up to three matches (one tap links)
* or "Ingen utgift med samma belopp runt det här datumet", "Välj en annan utgift", "Ignorera". Linked: "Öppna utgift",
* "Ta bort koppling". Ignored: "Sluta ignorera". Credit: the note, no action. Foreign currency: the note above
* "Skapa utgift". None of these needs the server (CP-9). Linked means [CardPurchase.linked] is set: the contract has no
* link while the expense is not on the phone (§14.2.4), so such a purchase shows the to-handle actions until it arrives.
*/
@Composable
fun PurchaseSheet(detail: CardPurchaseDetail, actions: CardsActions) {
val p = detail.row.purchase
val linked = p.linked
SheetColumn {
Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(p.merchant, style = MaterialTheme.typography.titleLarge)
Text(detailDate(p.date), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
if (p.amountOre != null) {
MoneyText(p.amountOre ?: 0L, AmountStyle.Large, AmountTone.Neutral)
} else {
Text(foreignAmount(p), style = MaterialTheme.typography.headlineSmall)
}
val pending = p.status == CardPurchaseStatus.PENDING
KvittBadge(stringResource(if (pending) R.string.cards_pending else R.string.cards_booked), kind = if (pending) BadgeKind.Pending else BadgeKind.Neutral)
if (p.direction == CardDirection.CREDIT) KvittBadge(stringResource(R.string.cards_credit))
}
when {
p.direction == CardDirection.CREDIT -> Note(stringResource(R.string.cards_sheet_credit_note))
linked != null -> {
Note(stringResource(R.string.cards_linked_to, linked.description, linked.accountName))
KvittButton(stringResource(R.string.cards_sheet_open_expense), { actions.onOpenExpense(linked.id) }, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.OpenInNew)
KvittButton(stringResource(R.string.cards_sheet_unlink), { actions.onUnlink(p.id) }, Modifier.fillMaxWidth(), kind = ButtonKind.Outlined, icon = KvittIcon.Close)
}
p.ignored -> KvittButton(stringResource(R.string.cards_sheet_unignore), { actions.onUnignore(p.id) }, Modifier.fillMaxWidth(), kind = ButtonKind.Tonal, icon = KvittIcon.Unarchive)
else -> ToHandle(detail, actions)
}
}
}
@Composable
private fun ToHandle(detail: CardPurchaseDetail, actions: CardsActions) {
val p = detail.row.purchase
if (p.amountOre == null) Note(stringResource(R.string.cards_sheet_foreign_note, p.originalCurrency.orEmpty()))
KvittButton(stringResource(R.string.cards_sheet_create), { actions.onCreateExpense(p.id) }, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Add)
Text(stringResource(R.string.cards_sheet_matches), style = MaterialTheme.typography.titleSmall)
val matches = detail.row.matches
if (matches.isEmpty()) {
Note(stringResource(R.string.cards_sheet_no_matches))
} else {
SegmentedGroup(matches.size) { item -> ExpenseMatchItem(item, matches[item.index], { actions.onLink(p.id, matches[item.index].id) }) }
}
KvittButton(stringResource(R.string.cards_sheet_other), actions.onOpenOthers, Modifier.fillMaxWidth(), kind = ButtonKind.Outlined, icon = KvittIcon.Link)
KvittButton(stringResource(R.string.cards_sheet_ignore), { actions.onIgnore(p.id) }, Modifier.fillMaxWidth(), kind = ButtonKind.Text, icon = KvittIcon.Block)
}
@Composable
private fun Note(text: String) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:cards, from docs/ux/strings.csv (module=cards; S-23 to S-27, K2). -->
<resources>
<!-- S-23 app bar -->
<string name="cards_title">Kortköp</string>
<!-- S-23 header (CP-11) -->
<string name="cards_private_note">Bara du ser dina kortköp</string>
<!-- S-23 header; %1$s §9.8 relative time -->
<string name="cards_updated">Uppdaterad %1$s</string>
<!-- S-23 header button (E-42, online only) -->
<string name="cards_refresh">Uppdatera</string>
<!-- S-23 header button while E-42 runs -->
<string name="cards_refreshing">Uppdaterar…</string>
<!-- S-23 filter chip (default) -->
<string name="cards_filter_to_handle">Att hantera</string>
<!-- S-23 filter chip -->
<string name="cards_filter_linked">Kopplade</string>
<!-- S-23 filter chip -->
<string name="cards_filter_ignored">Ignorerade</string>
<!-- S-23 filter chip -->
<string name="cards_filter_all">Alla</string>
<!-- S-23 row badge and sheet status of a pending purchase -->
<string name="cards_pending">Preliminär</string>
<!-- S-23 sheet status of a booked purchase -->
<string name="cards_booked">Bokförd</string>
<!-- S-23 badge of a credit (only under Alla, CP-8) -->
<string name="cards_credit">Insättning</string>
<!-- S-23 badge of a foreign-currency purchase (CP-7) -->
<string name="cards_foreign">Utländsk valuta</string>
<!-- S-23 foreign amount; %1$s the bank\'s decimal amount, %2$s ISO 4217 currency; K2 addition -->
<string name="cards_foreign_amount">%1$s %2$s</string>
<!-- S-23 row chip with exactly one match (CP-10); %1$s the expense\'s description -->
<string name="cards_match_one">Koppla till %1$s</string>
<!-- plural one; S-23 row chip with two or three matches -->
<plurals name="cards_match_many">
<item quantity="one">%1$d möjlig utgift</item>
<item quantity="other">%1$d möjliga utgifter</item>
</plurals>
<!-- S-23 row under Kopplade; %1$s description, %2$s account -->
<string name="cards_linked_to">Kopplad till %1$s · %2$s</string>
<!-- S-23 empty state (no connection) -->
<string name="cards_empty_title">Anslut ditt kort</string>
<!-- S-23 empty state -->
<string name="cards_empty_body">Se dina köp här och gör om dem till utgifter med några tryck. Bara du ser dem.</string>
<!-- S-23 empty state button, opens S-24 -->
<string name="cards_connect">Anslut kort</string>
<!-- S-23 while the first fetch runs -->
<string name="cards_first_fetch">Hämtar dina köp…</string>
<!-- S-23 empty Att hantera -->
<string name="cards_nothing_to_handle">Inget att hantera</string>
<!-- S-23 empty Att hantera -->
<string name="cards_nothing_to_handle_body">Nya kortköp visas här.</string>
<!-- S-23 empty Kopplade, Ignorerade, Alla -->
<string name="cards_filter_empty">Inga köp här</string>
<!-- S-23 banner from 14 days before valid_until (CP-6); %1$s list date -->
<string name="cards_expiring">Kortanslutningen upphör %1$s</string>
<!-- S-23 banner (CP-6) -->
<string name="cards_expired">Kortanslutningen har upphört</string>
<!-- S-23 banners, S-25 renewal, S-26 -->
<string name="cards_renew">Förnya med BankID</string>
<!-- S-23 banner (CP-14); %1$s §9.8 relative time -->
<string name="cards_fetch_failed">Kunde inte hämta köp. Senast uppdaterad %1$s</string>
<!-- S-23 sheet, opens S-7 prefilled (CP-9) -->
<string name="cards_sheet_create">Skapa utgift</string>
<!-- S-23 sheet section (CP-10) -->
<string name="cards_sheet_matches">Koppla till befintlig utgift</string>
<!-- S-23 sheet without matches -->
<string name="cards_sheet_no_matches">Ingen utgift med samma belopp runt det här datumet</string>
<!-- S-23 sheet, opens the second sheet (CP-10) -->
<string name="cards_sheet_other">Välj en annan utgift</string>
<!-- S-23 sheet -->
<string name="cards_sheet_ignore">Ignorera</string>
<!-- S-23 sheet of an ignored purchase -->
<string name="cards_sheet_unignore">Sluta ignorera</string>
<!-- S-23 sheet of a linked purchase -->
<string name="cards_sheet_unlink">Ta bort koppling</string>
<!-- S-23 sheet of a linked purchase, opens S-8 -->
<string name="cards_sheet_open_expense">Öppna utgift</string>
<!-- S-23 sheet of a credit (CP-8) -->
<string name="cards_sheet_credit_note">Insättningar och återbetalningar är inga utgifter. Ändra den ursprungliga utgiften för hand vid behov.</string>
<!-- S-23 sheet above Skapa utgift; %1$s ISO 4217 currency -->
<string name="cards_sheet_foreign_note">Beloppet är i %1$s: skriv beloppet i kronor.</string>
<!-- S-23 snackbar with common_undo -->
<string name="cards_snackbar_linked">Kopplad</string>
<!-- S-23 snackbar with common_undo -->
<string name="cards_snackbar_ignored">Ignorerad</string>
<!-- TalkBack click label of S-23\'s header card, which opens S-26; K2 addition -->
<string name="cards_connection_settings">Kortanslutning</string>
<!-- S-24 app bar -->
<string name="cards_connect_title">Anslut kort</string>
<!-- S-24 intro -->
<string name="cards_connect_intro">Välj ditt kort. Kvitt läser dina köp men kan aldrig betala eller flytta pengar.</string>
<!-- S-24 offline banner body under common_needs_connection; K2 addition -->
<string name="cards_connect_offline_body">Listan över kort hämtas från servern. Anslut telefonen till internet.</string>
<!-- S-24 tile of a connected card; %1$s list date -->
<string name="cards_provider_connected">Ansluten · giltig till %1$s</string>
<!-- S-24 tile of a provider without an application (CP-13) -->
<string name="cards_provider_unavailable">Inte konfigurerat på servern</string>
<!-- S-25 history choice (CP-3) -->
<string name="cards_history_label">Hämta köp från de senaste</string>
<!-- plural one; S-25 and S-26 options -->
<plurals name="cards_history_days">
<item quantity="one">%1$d dag</item>
<item quantity="other">%1$d dagar</item>
</plurals>
<!-- S-25 under the choice -->
<string name="cards_history_note">Du kan korta perioden senare. En längre period kräver BankID igen.</string>
<!-- S-25 button (E-37) -->
<string name="cards_connect_bankid">Anslut med BankID</string>
<!-- S-25 while polling E-38 -->
<string name="cards_waiting">Väntar på BankID…</string>
<!-- S-25 waiting -->
<string name="cards_waiting_body">Slutför i webbläsaren och i BankID och kom sedan tillbaka hit.</string>
<!-- S-25 connected, then S-23 -->
<string name="cards_connected">Kortet är anslutet</string>
<!-- S-25 failed, with common_retry -->
<string name="cards_connect_failed">Det gick inte att ansluta kortet</string>
<!-- S-25 attempt expired, with common_retry -->
<string name="cards_connect_timeout">Anslutningsförsöket tog för lång tid</string>
<!-- S-26 status line -->
<string name="cards_status_active">Ansluten</string>
<!-- S-26 status line -->
<string name="cards_status_expired">Har upphört</string>
<!-- S-26; %1$s detail date -->
<string name="cards_valid_until">Giltig till %1$s</string>
<!-- S-26; %1$s §9.8 relative time -->
<string name="cards_last_updated">Senast uppdaterad %1$s</string>
<!-- S-26 (E-41, shorter only) -->
<string name="cards_history_setting">Historik vid anslutning</string>
<!-- S-26 under the history setting -->
<string name="cards_history_longer_note">En längre period kräver BankID igen</string>
<!-- S-26 button and dialog confirm (CP-12) -->
<string name="cards_disconnect">Koppla från</string>
<!-- S-26 dialog title; %1$s card name -->
<string name="cards_disconnect_title">Koppla från %1$s?</string>
<!-- S-26 dialog body -->
<string name="cards_disconnect_body">Köp som du inte har kopplat eller ignorerat raderas. Utgifter du har skapat finns kvar.</string>
<!-- S-26 dialog checkbox (erase_handled) -->
<string name="cards_disconnect_erase_all">Radera även kopplade och ignorerade köp</string>
<!-- S-27 app bar, the account\'s name as subtitle -->
<string name="cards_pick_title">Från kortköp</string>
<!-- S-27 empty -->
<string name="cards_pick_empty">Inga kortköp att hantera</string>
</resources>
@@ -0,0 +1,144 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:cards, from docs/ux/strings.csv (module=cards; S-23 to S-27, K2). -->
<resources>
<!-- S-23 app bar -->
<string name="cards_title">Card purchases</string>
<!-- S-23 header (CP-11) -->
<string name="cards_private_note">Only you see your card purchases</string>
<!-- S-23 header; %1$s §9.8 relative time -->
<string name="cards_updated">Updated %1$s</string>
<!-- S-23 header button (E-42, online only) -->
<string name="cards_refresh">Refresh</string>
<!-- S-23 header button while E-42 runs -->
<string name="cards_refreshing">Refreshing…</string>
<!-- S-23 filter chip (default) -->
<string name="cards_filter_to_handle">To handle</string>
<!-- S-23 filter chip -->
<string name="cards_filter_linked">Linked</string>
<!-- S-23 filter chip -->
<string name="cards_filter_ignored">Ignored</string>
<!-- S-23 filter chip -->
<string name="cards_filter_all">All</string>
<!-- S-23 row badge and sheet status of a pending purchase -->
<string name="cards_pending">Pending</string>
<!-- S-23 sheet status of a booked purchase -->
<string name="cards_booked">Booked</string>
<!-- S-23 badge of a credit (only under Alla, CP-8) -->
<string name="cards_credit">Deposit</string>
<!-- S-23 badge of a foreign-currency purchase (CP-7) -->
<string name="cards_foreign">Foreign currency</string>
<!-- S-23 foreign amount; %1$s the bank\'s decimal amount, %2$s ISO 4217 currency; K2 addition -->
<string name="cards_foreign_amount">%1$s %2$s</string>
<!-- S-23 row chip with exactly one match (CP-10); %1$s the expense\'s description -->
<string name="cards_match_one">Link to %1$s</string>
<!-- plural one; S-23 row chip with two or three matches -->
<plurals name="cards_match_many">
<item quantity="one">%1$d possible expense</item>
<item quantity="other">%1$d possible expenses</item>
</plurals>
<!-- S-23 row under Kopplade; %1$s description, %2$s account -->
<string name="cards_linked_to">Linked to %1$s · %2$s</string>
<!-- S-23 empty state (no connection) -->
<string name="cards_empty_title">Connect your card</string>
<!-- S-23 empty state -->
<string name="cards_empty_body">See your purchases here and turn them into expenses in a few taps. Only you see them.</string>
<!-- S-23 empty state button, opens S-24 -->
<string name="cards_connect">Connect card</string>
<!-- S-23 while the first fetch runs -->
<string name="cards_first_fetch">Fetching your purchases…</string>
<!-- S-23 empty Att hantera -->
<string name="cards_nothing_to_handle">Nothing to handle</string>
<!-- S-23 empty Att hantera -->
<string name="cards_nothing_to_handle_body">New card purchases show up here.</string>
<!-- S-23 empty Kopplade, Ignorerade, Alla -->
<string name="cards_filter_empty">No purchases here</string>
<!-- S-23 banner from 14 days before valid_until (CP-6); %1$s list date -->
<string name="cards_expiring">Your card connection ends on %1$s</string>
<!-- S-23 banner (CP-6) -->
<string name="cards_expired">Your card connection has ended</string>
<!-- S-23 banners, S-25 renewal, S-26 -->
<string name="cards_renew">Renew with BankID</string>
<!-- S-23 banner (CP-14); %1$s §9.8 relative time -->
<string name="cards_fetch_failed">Could not fetch purchases. Last updated %1$s</string>
<!-- S-23 sheet, opens S-7 prefilled (CP-9) -->
<string name="cards_sheet_create">Create expense</string>
<!-- S-23 sheet section (CP-10) -->
<string name="cards_sheet_matches">Link to an existing expense</string>
<!-- S-23 sheet without matches -->
<string name="cards_sheet_no_matches">No expense with the same amount around this date</string>
<!-- S-23 sheet, opens the second sheet (CP-10) -->
<string name="cards_sheet_other">Choose another expense</string>
<!-- S-23 sheet -->
<string name="cards_sheet_ignore">Ignore</string>
<!-- S-23 sheet of an ignored purchase -->
<string name="cards_sheet_unignore">Stop ignoring</string>
<!-- S-23 sheet of a linked purchase -->
<string name="cards_sheet_unlink">Remove link</string>
<!-- S-23 sheet of a linked purchase, opens S-8 -->
<string name="cards_sheet_open_expense">Open expense</string>
<!-- S-23 sheet of a credit (CP-8) -->
<string name="cards_sheet_credit_note">Deposits and refunds are not expenses. Change the original expense by hand if needed.</string>
<!-- S-23 sheet above Skapa utgift; %1$s ISO 4217 currency -->
<string name="cards_sheet_foreign_note">The amount is in %1$s: type the amount in kronor.</string>
<!-- S-23 snackbar with common_undo -->
<string name="cards_snackbar_linked">Linked</string>
<!-- S-23 snackbar with common_undo -->
<string name="cards_snackbar_ignored">Ignored</string>
<!-- TalkBack click label of S-23\'s header card, which opens S-26; K2 addition -->
<string name="cards_connection_settings">Card connection</string>
<!-- S-24 app bar -->
<string name="cards_connect_title">Connect card</string>
<!-- S-24 intro -->
<string name="cards_connect_intro">Choose your card. Kvitt reads your purchases but can never pay or move money.</string>
<!-- S-24 offline banner body under common_needs_connection; K2 addition -->
<string name="cards_connect_offline_body">The list of cards comes from the server. Connect the phone to the internet.</string>
<!-- S-24 tile of a connected card; %1$s list date -->
<string name="cards_provider_connected">Connected · valid until %1$s</string>
<!-- S-24 tile of a provider without an application (CP-13) -->
<string name="cards_provider_unavailable">Not set up on the server</string>
<!-- S-25 history choice (CP-3) -->
<string name="cards_history_label">Fetch purchases from the last</string>
<!-- plural one; S-25 and S-26 options -->
<plurals name="cards_history_days">
<item quantity="one">%1$d day</item>
<item quantity="other">%1$d days</item>
</plurals>
<!-- S-25 under the choice -->
<string name="cards_history_note">You can shorten this later. A longer period needs BankID again.</string>
<!-- S-25 button (E-37) -->
<string name="cards_connect_bankid">Connect with BankID</string>
<!-- S-25 while polling E-38 -->
<string name="cards_waiting">Waiting for BankID…</string>
<!-- S-25 waiting -->
<string name="cards_waiting_body">Finish in the browser and in BankID, then come back here.</string>
<!-- S-25 connected, then S-23 -->
<string name="cards_connected">Card connected</string>
<!-- S-25 failed, with common_retry -->
<string name="cards_connect_failed">The card could not be connected</string>
<!-- S-25 attempt expired, with common_retry -->
<string name="cards_connect_timeout">The connection attempt took too long</string>
<!-- S-26 status line -->
<string name="cards_status_active">Connected</string>
<!-- S-26 status line -->
<string name="cards_status_expired">Ended</string>
<!-- S-26; %1$s detail date -->
<string name="cards_valid_until">Valid until %1$s</string>
<!-- S-26; %1$s §9.8 relative time -->
<string name="cards_last_updated">Last updated %1$s</string>
<!-- S-26 (E-41, shorter only) -->
<string name="cards_history_setting">History when connecting</string>
<!-- S-26 under the history setting -->
<string name="cards_history_longer_note">A longer period needs BankID again</string>
<!-- S-26 button and dialog confirm (CP-12) -->
<string name="cards_disconnect">Disconnect</string>
<!-- S-26 dialog title; %1$s card name -->
<string name="cards_disconnect_title">Disconnect %1$s?</string>
<!-- S-26 dialog body -->
<string name="cards_disconnect_body">Purchases you have not linked or ignored are deleted. Expenses you created stay.</string>
<!-- S-26 dialog checkbox (erase_handled) -->
<string name="cards_disconnect_erase_all">Also delete linked and ignored purchases</string>
<!-- S-27 app bar, the account\'s name as subtitle -->
<string name="cards_pick_title">From card purchases</string>
<!-- S-27 empty -->
<string name="cards_pick_empty">No card purchases to handle</string>
</resources>
@@ -0,0 +1,206 @@
package se.hagstedt.kvitt.feature.cards
import androidx.lifecycle.SavedStateHandle
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import se.hagstedt.kvitt.design.testing.MainDispatcherRule
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.cards.CardScenarios
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.model.CardAttemptState
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.model.CardRepository
import se.hagstedt.kvitt.model.OnlineResult
/** The state holders of S-23 to S-27 on the fakes (SPEC §13.3, §9.12 CP-2, CP-3, CP-9, CP-12). */
@OptIn(ExperimentalCoroutinesApi::class)
class CardStateHoldersTest {
@get:Rule
val main = MainDispatcherRule()
private fun cards(scenario: FakeScenario): Pair<CardsViewModel, FakeRepositories> {
val fakes = FakeRepositories(scenario)
return CardsViewModel(fakes.cards, fakes.connectivity) to fakes
}
@Test
fun s23_startsUnderToHandle_withMatchChips_andTheFiltersSwitch() = runTest(main.dispatcher) {
val (vm, _) = cards(CardScenarios.loaded)
val ui = vm.ui.value
assertEquals(CardFilter.TO_HANDLE, ui.cards?.filter)
val rows = ui.cards?.days.orEmpty().flatMap { it.rows }
assertEquals("ICA Maxi Lindhagen", rows.first { it.purchase.id == CardScenarios.ica.id }.matches.single().description)
assertEquals(2, rows.first { it.purchase.id == CardScenarios.twoMatches.id }.matches.size)
assertTrue(rows.none { it.purchase.id == CardScenarios.payment.id })
vm.setFilter(CardFilter.ALL)
assertTrue(vm.ui.value.cards?.days.orEmpty().flatMap { it.rows }.any { it.purchase.id == CardScenarios.payment.id })
}
@Test
fun s23_linkFromTheChip_isLocal_showsKopplad_andUndoUnlinks() = runTest(main.dispatcher) {
val (vm, fakes) = cards(CardScenarios.offline)
vm.link(CardScenarios.ica.id, HomeScenarios.icaMaxi.id)
assertEquals(HomeScenarios.icaMaxi.id, fakes.store.current.cards.purchases.first { it.id == CardScenarios.ica.id }.expenseId)
val message = vm.ui.value.message
assertEquals(CardsMessage.Linked(CardScenarios.ica.id), message)
vm.messageShown()
vm.undo(requireNotNull(message))
assertNull(fakes.store.current.cards.purchases.first { it.id == CardScenarios.ica.id }.expenseId)
}
@Test
fun s23_ignore_closesTheSheet_andUndoStopsIgnoring() = runTest(main.dispatcher) {
val (vm, fakes) = cards(CardScenarios.loaded)
vm.openPurchase(CardScenarios.wolt.id)
assertEquals(CardsSheet.Purchase, vm.ui.value.sheet)
vm.openOthers()
assertEquals(CardsSheet.Others, vm.ui.value.sheet)
vm.dismissSheet()
assertEquals(CardsSheet.Purchase, vm.ui.value.sheet)
vm.ignore(CardScenarios.wolt.id)
assertNull(vm.ui.value.sheet)
assertTrue(fakes.store.current.cards.purchases.first { it.id == CardScenarios.wolt.id }.ignored)
vm.undo(CardsMessage.Ignored(CardScenarios.wolt.id))
assertFalse(fakes.store.current.cards.purchases.first { it.id == CardScenarios.wolt.id }.ignored)
}
@Test
fun s23_states_readTheConnection() = runTest(main.dispatcher) {
assertNull(cards(CardScenarios.none).first.ui.value.connection)
assertTrue(cards(CardScenarios.firstFetch).first.ui.value.firstFetch)
assertTrue(cards(CardScenarios.expiring).first.ui.value.expiring)
val expired = cards(CardScenarios.expired).first.ui.value
assertTrue(expired.expired)
assertFalse(expired.fetchFailed)
assertFalse(expired.canRefresh)
assertTrue(cards(CardScenarios.fetchFailed).first.ui.value.fetchFailed)
assertFalse(cards(CardScenarios.offline).first.ui.value.canRefresh)
}
@Test
fun s23_pullToRefreshOffline_endsAtOnce() = runTest(main.dispatcher) {
val (vm, _) = cards(CardScenarios.offline)
vm.pullToRefresh()
assertFalse(vm.ui.value.refreshing)
}
/** Counts E-38 calls. */
private class CountingCards(private val delegate: CardRepository) : CardRepository by delegate {
var attempts = 0
override suspend fun attempt(attemptId: String): OnlineResult<CardAttemptState> {
attempts++
return delegate.attempt(attemptId)
}
}
@Test
fun s25_pollsE38EveryTwoSecondsWhileResumed_andStopsWhenPaused() = runTest(StandardTestDispatcher(main.dispatcher.scheduler)) {
val fakes = FakeRepositories(CardScenarios.attemptPending)
val counting = CountingCards(fakes.cards)
val vm = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), counting, fakes.connectivity)
val events = mutableListOf<ConnectEvent>()
backgroundScope.launch { vm.eventFlow.collect { events += it } }
vm.onResume()
vm.start()
runCurrent()
assertEquals(ConnectPhase.Waiting, vm.ui.value.phase)
assertTrue(events.single() is ConnectEvent.OpenBank)
advanceTimeBy(CardConnectViewModel.POLL_MILLIS * 3 + 1)
assertEquals(3, counting.attempts)
vm.onPause()
advanceTimeBy(CardConnectViewModel.POLL_MILLIS * 3)
assertEquals(3, counting.attempts)
vm.onResume()
advanceTimeBy(CardConnectViewModel.POLL_MILLIS + 1)
assertEquals(4, counting.attempts)
vm.cancel()
advanceTimeBy(CardConnectViewModel.POLL_MILLIS * 3)
assertEquals(4, counting.attempts)
assertEquals(ConnectPhase.Choose, vm.ui.value.phase)
}
@Test
fun s25_connected_showsTheResult_thenGoesOn() = runTest(StandardTestDispatcher(main.dispatcher.scheduler)) {
val fakes = FakeRepositories(CardScenarios.attemptConnected)
val vm = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), fakes.cards, fakes.connectivity)
val events = mutableListOf<ConnectEvent>()
backgroundScope.launch { vm.eventFlow.collect { events += it } }
vm.onResume()
vm.start()
advanceTimeBy(CardConnectViewModel.POLL_MILLIS + 1)
assertEquals(ConnectPhase.Connected, vm.ui.value.phase)
assertTrue(fakes.cards.hasConnection.first())
advanceTimeBy(CardConnectViewModel.CONNECTED_MILLIS + 1)
assertEquals(ConnectEvent.Done, events.last())
}
@Test
fun s25_failedAndExpiredAttempts_andTheReturnLinkAsksAtOnce() = runTest(main.dispatcher) {
val args = mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")
val failed = FakeRepositories(CardScenarios.attemptFailed)
val vm = CardConnectViewModel(SavedStateHandle(args), failed.cards, failed.connectivity)
vm.onReturn("01920000-0000-7000-8000-0000000000aa")
assertEquals(ConnectPhase.Failed, vm.ui.value.phase)
val expired = FakeRepositories(CardScenarios.attemptExpired)
val other = CardConnectViewModel(SavedStateHandle(args + (CardConnectViewModel.ARG_ATTEMPT to "01920000-0000-7000-8000-0000000000aa")), expired.cards, expired.connectivity)
advanceUntilIdle()
assertEquals(ConnectPhase.TimedOut, other.ui.value.phase)
}
@Test
fun s25_historyDefaultsTo30_aRenewalStartsFromTheConnections_andOfflineCannotStart() = runTest(main.dispatcher) {
val none = FakeRepositories(CardScenarios.none)
val vm = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), none.cards, none.connectivity)
assertEquals(30, vm.ui.value.historyDays)
vm.setHistory(180)
assertEquals(180, vm.ui.value.historyDays)
val renew = FakeRepositories(CardScenarios.loaded.copy(cards = CardScenarios.loaded.cards.copy(connection = CardScenarios.connection.copy(historyDays = 90))))
val renewing = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard", CardConnectViewModel.ARG_RENEW to "true")), renew.cards, renew.connectivity)
assertEquals(90, renewing.ui.value.historyDays)
assertTrue(renewing.ui.value.renew)
val offline = FakeRepositories(CardScenarios.connectOffline)
val off = CardConnectViewModel(SavedStateHandle(mapOf(CardConnectViewModel.ARG_PROVIDER_ID to "sj_prio_mastercard")), offline.cards, offline.connectivity)
off.start()
assertEquals(ConnectPhase.Choose, off.ui.value.phase)
assertFalse(off.ui.value.online)
}
@Test
fun s26_historyOnlyShorter_andDisconnectKeepsTheHandledPurchases() = runTest(main.dispatcher) {
val fakes = FakeRepositories(CardScenarios.loaded)
val vm = CardConnectionViewModel(SavedStateHandle(mapOf(CardConnectionViewModel.ARG_CONNECTION_ID to CardScenarios.connectionId.value)), fakes.cards, fakes.connectivity)
val events = mutableListOf<ConnectionEvent>()
backgroundScope.launch { vm.eventFlow.collect { events += it } }
vm.setHistory(90)
assertEquals(30, fakes.store.current.cards.connection?.historyDays)
vm.setHistory(7)
assertEquals(7, fakes.store.current.cards.connection?.historyDays)
vm.askDisconnect()
assertTrue(vm.ui.value.disconnectDialog)
vm.confirmDisconnect()
assertEquals(listOf<ConnectionEvent>(ConnectionEvent.Disconnected), events)
assertEquals(setOf(CardScenarios.willys.id, CardScenarios.bauhaus.id, CardScenarios.spotify.id), fakes.store.current.cards.purchases.map { it.id }.toSet())
}
@Test
fun s27_listsThePurchasesToHandle_withTheAccountsName() = runTest(main.dispatcher) {
val fakes = FakeRepositories(CardScenarios.loaded)
val vm = CardPickViewModel(SavedStateHandle(mapOf(CardPickViewModel.ARG_ACCOUNT_ID to HomeScenarios.vardag.id.value)), fakes.cards, fakes.accounts)
assertEquals("Vardag", vm.ui.value.accountName)
val ids = vm.ui.value.purchases.orEmpty().map { it.id }
assertEquals(listOf(CardScenarios.pressbyran.id, CardScenarios.systembolaget.id, CardScenarios.ica.id, CardScenarios.wolt.id, CardScenarios.amazon.id, CardScenarios.twoMatches.id), ids)
}
}
@@ -0,0 +1,212 @@
package se.hagstedt.kvitt.feature.cards.screenshot
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.lifecycle.SavedStateHandle
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import se.hagstedt.kvitt.design.component.ConfirmDialogContent
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
import se.hagstedt.kvitt.fakes.FakeCardPurchase
import se.hagstedt.kvitt.fakes.FakeCardRepository
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.fakes.cards.CardScenarios
import se.hagstedt.kvitt.fakes.home.HomeScenarios
import se.hagstedt.kvitt.feature.cards.CardConnectActions
import se.hagstedt.kvitt.feature.cards.CardConnectContent
import se.hagstedt.kvitt.feature.cards.CardConnectViewModel
import se.hagstedt.kvitt.feature.cards.CardConnectionActions
import se.hagstedt.kvitt.feature.cards.CardConnectionContent
import se.hagstedt.kvitt.feature.cards.CardConnectionViewModel
import se.hagstedt.kvitt.feature.cards.CardPickContent
import se.hagstedt.kvitt.feature.cards.CardPickViewModel
import se.hagstedt.kvitt.feature.cards.CardsActions
import se.hagstedt.kvitt.feature.cards.CardsContent
import se.hagstedt.kvitt.feature.cards.CardsSheet
import se.hagstedt.kvitt.feature.cards.CardsViewModel
import se.hagstedt.kvitt.feature.cards.ConnectCardContent
import se.hagstedt.kvitt.feature.cards.ConnectCardViewModel
import se.hagstedt.kvitt.feature.cards.EraseAll
import se.hagstedt.kvitt.feature.cards.OthersSheet
import se.hagstedt.kvitt.feature.cards.PurchaseSheet
import se.hagstedt.kvitt.feature.cards.R
import se.hagstedt.kvitt.model.CardFilter
import se.hagstedt.kvitt.core.design.R as DesignR
/** One golden state: its name, whether it has a 200 % golden, and its content. */
class CardCase(val name: String, val largeFont: Boolean = false, val content: () -> @Composable () -> Unit)
/** The household every card screen is rendered in (the match rows name the payer). */
@Composable
private fun Household(content: @Composable () -> Unit) = ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner, content)
/** S-23 in [scenario] after [steps], with the purchase sheet or "Välj en annan utgift" when one is open. */
private fun s23(name: String, scenario: FakeScenario, largeFont: Boolean = false, steps: CardsViewModel.() -> Unit = {}) = CardCase(name, largeFont) {
val ui = buildState { idle ->
val fakes = FakeRepositories(scenario)
val vm = CardsViewModel(fakes.cards, fakes.connectivity)
idle()
vm.steps()
idle()
vm.ui.value
}
val detail = ui.detail
if (ui.sheet == null || detail == null) {
{ Household { CardsContent(ui, CardsActions()) } }
} else {
{
Household {
OverlayFrame(sheet = true, { CardsContent(ui.copy(sheet = null), CardsActions()) }) {
if (ui.sheet == CardsSheet.Others) OthersSheet(detail.row.purchase.id, detail.others, CardsActions()) else PurchaseSheet(detail, CardsActions())
}
}
}
}
}
private fun open(p: FakeCardPurchase): CardsViewModel.() -> Unit = { openPurchase(p.id) }
private fun s24(name: String, scenario: FakeScenario, largeFont: Boolean = false) = CardCase(name, largeFont) {
val ui = buildState { idle ->
val fakes = FakeRepositories(scenario)
val vm = ConnectCardViewModel(fakes.cards, fakes.connectivity)
idle()
vm.ui.value
}
({ ConnectCardContent(ui, {}, {}, {}, {}) })
}
private fun s25(name: String, scenario: FakeScenario, renew: Boolean = false, largeFont: Boolean = false, steps: CardConnectViewModel.() -> Unit = {}) = CardCase(name, largeFont) {
val ui = buildState { idle ->
val fakes = FakeRepositories(scenario)
val args = buildMap {
put(CardConnectViewModel.ARG_PROVIDER_ID, "sj_prio_mastercard")
if (renew) put(CardConnectViewModel.ARG_RENEW, "true")
}
val vm = CardConnectViewModel(SavedStateHandle(args), fakes.cards, fakes.connectivity)
idle()
vm.steps()
// The state before the 1.5 s hand-over to S-23 (connected) is what the screen shows.
vm.ui.value
}
({ CardConnectContent(ui, CardConnectActions()) })
}
private fun s26(name: String, scenario: FakeScenario, largeFont: Boolean = false, dialog: Boolean = false) = CardCase(name, largeFont) {
val ui = buildState { idle ->
val fakes = FakeRepositories(scenario)
val vm = CardConnectionViewModel(SavedStateHandle(mapOf(CardConnectionViewModel.ARG_CONNECTION_ID to CardScenarios.connectionId.value)), fakes.cards, fakes.connectivity)
idle()
if (dialog) vm.askDisconnect()
vm.ui.value
}
if (!dialog) {
{ CardConnectionContent(ui, CardConnectionActions()) }
} else {
{
OverlayFrame(sheet = false, { CardConnectionContent(ui.copy(disconnectDialog = false), CardConnectionActions()) }) {
ConfirmDialogContent(
icon = KvittIcon.Delete,
title = stringResource(R.string.cards_disconnect_title, ui.provider?.name.orEmpty()),
body = stringResource(R.string.cards_disconnect_body),
confirm = stringResource(R.string.cards_disconnect),
dismiss = stringResource(DesignR.string.common_cancel),
onConfirm = {},
onDismiss = {},
destructive = true,
extra = { EraseAll(ui.eraseHandled) {} },
)
}
}
}
}
private fun s27(name: String, scenario: FakeScenario, largeFont: Boolean = false) = CardCase(name, largeFont) {
val ui = buildState { idle ->
val fakes = FakeRepositories(scenario)
val vm = CardPickViewModel(SavedStateHandle(mapOf(CardPickViewModel.ARG_ACCOUNT_ID to HomeScenarios.vardag.id.value)), fakes.cards, fakes.accounts)
idle()
vm.ui.value
}
({ CardPickContent(ui, {}, {}) })
}
/** Every state of S-23 to S-27 (§13.3, PLAN-K2 §5) × sv light, sv dark, en light, plus 200 % for the long ones. */
val cardCases: List<CardCase> = listOf(
s23("s23_no_connection", CardScenarios.none),
s23("s23_first_fetch", CardScenarios.firstFetch),
s23("s23_to_handle", CardScenarios.loaded),
s23("s23_linked", CardScenarios.loaded) { setFilter(CardFilter.LINKED) },
s23("s23_ignored", CardScenarios.loaded) { setFilter(CardFilter.IGNORED) },
s23("s23_all", CardScenarios.loaded) { setFilter(CardFilter.ALL) },
s23("s23_nothing_to_handle", CardScenarios.nothingToHandle),
s23("s23_filter_empty", CardScenarios.noPurchases) { setFilter(CardFilter.LINKED) },
s23("s23_offline", CardScenarios.offline),
s23("s23_refreshing", CardScenarios.refreshing),
s23("s23_expiring", CardScenarios.expiring),
s23("s23_expired", CardScenarios.expired),
s23("s23_fetch_failed", CardScenarios.fetchFailed),
s23("s23_long", CardScenarios.long, largeFont = true),
s23("s23_sheet_one_match", CardScenarios.loaded, largeFont = true, steps = open(CardScenarios.ica)),
s23("s23_sheet_two_matches", CardScenarios.loaded, steps = open(CardScenarios.twoMatches)),
s23("s23_sheet_no_match", CardScenarios.loaded, steps = open(CardScenarios.wolt)),
s23("s23_sheet_foreign", CardScenarios.loaded, steps = open(CardScenarios.amazon)),
s23("s23_sheet_linked", CardScenarios.loaded, steps = open(CardScenarios.willys)),
s23("s23_sheet_ignored", CardScenarios.loaded, steps = open(CardScenarios.spotify)),
s23("s23_sheet_credit", CardScenarios.loaded, steps = open(CardScenarios.payment)),
s23("s23_sheet_other", CardScenarios.loaded) { openPurchase(CardScenarios.wolt.id); openOthers() },
s24("s24_catalogue", CardScenarios.none, largeFont = true),
s24("s24_connected", CardScenarios.loaded),
s24("s24_unavailable", CardScenarios.unavailable),
s24("s24_fallback_artwork", CardScenarios.unknownArtwork),
s24("s24_loading", CardScenarios.providersLoading),
s24("s24_offline", CardScenarios.connectOffline),
s24("s24_error", CardScenarios.providersFailed),
s25("s25_choose", CardScenarios.none, largeFont = true),
s25("s25_renew", CardScenarios.expiring, renew = true),
s25("s25_waiting", CardScenarios.attemptPending) { start() },
s25("s25_connected", CardScenarios.attemptConnected) { onReturn(FakeCardRepository.ATTEMPT_ID) },
s25("s25_failed", CardScenarios.attemptFailed) { onReturn(FakeCardRepository.ATTEMPT_ID) },
s25("s25_timed_out", CardScenarios.attemptExpired) { onReturn(FakeCardRepository.ATTEMPT_ID) },
s25("s25_offline", CardScenarios.connectOffline),
s26("s26_active", CardScenarios.loaded, largeFont = true),
s26("s26_expiring", CardScenarios.expiring),
s26("s26_expired", CardScenarios.expired),
s26("s26_offline", CardScenarios.offline),
s26("s26_disconnect", CardScenarios.loaded, dialog = true),
s27("s27_to_handle", CardScenarios.loaded, largeFont = true),
s27("s27_empty", CardScenarios.nothingToHandle),
)
/**
* Goldens of every card screen state. `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:cards:recordRoborazziDebug` and open every changed PNG.
*/
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = ScreenSnapshots.QUALIFIERS)
class CardsScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Test
fun screen() {
val case = cardCases.first { it.name == name }
ScreenSnapshots.snap(compose, case.name, case.largeFont, jvmLocale = true, content = case.content())
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun cases(): List<Array<Any>> = cardCases.map { arrayOf<Any>(it.name) }
}
}
@@ -0,0 +1,66 @@
package se.hagstedt.kvitt.feature.cards.screenshot
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import se.hagstedt.kvitt.design.theme.KvittDimens
/**
* Runs [setup] with the view models' main dispatcher set to a test dispatcher and returns its result; [setup] calls
* the given `idle` after actions that wait.
*/
@OptIn(ExperimentalCoroutinesApi::class)
fun <T> buildState(setup: (idle: () -> Unit) -> T): T {
val dispatcher = UnconfinedTestDispatcher()
Dispatchers.setMain(dispatcher)
val out = mutableListOf<T>()
try {
runTest(dispatcher) { out += setup { advanceUntilIdle() } }
} finally {
Dispatchers.resetMain()
}
return out.single()
}
/**
* A sheet or dialog drawn over the screen in the same window, as `:feature:expense`'s goldens do: Robolectric captures
* of separate windows lose the configuration's locale, so the goldens draw the real content in the Material frame.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun OverlayFrame(sheet: Boolean, base: @Composable () -> Unit, overlay: @Composable () -> Unit) {
Box(Modifier.fillMaxSize()) {
base()
Box(Modifier.matchParentSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = if (sheet) SHEET_SCRIM else DIALOG_SCRIM)))
if (sheet) {
Surface(Modifier.align(Alignment.BottomCenter).fillMaxWidth(), shape = BottomSheetDefaults.ExpandedShape, color = BottomSheetDefaults.ContainerColor) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
BottomSheetDefaults.DragHandle()
Box(Modifier.fillMaxWidth()) { overlay() }
}
}
} else {
Box(Modifier.align(Alignment.Center).padding(horizontal = KvittDimens.dialogPadding)) { overlay() }
}
}
}
private const val SHEET_SCRIM = 0.32f
private const val DIALOG_SCRIM = 0.38f
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Some files were not shown because too many files have changed in this diff Show More