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
@@ -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
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.8 KiB |