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
@@ -22,7 +22,13 @@ import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
import se.hagstedt.kvitt.design.component.kvittLanguage
|
||||
import se.hagstedt.kvitt.design.format.DateFormatter
|
||||
import se.hagstedt.kvitt.model.CardConnectionStatus
|
||||
import kotlin.time.Instant
|
||||
import se.hagstedt.kvitt.design.component.AvatarPlaceholder
|
||||
import se.hagstedt.kvitt.design.component.AvatarShape
|
||||
import se.hagstedt.kvitt.design.component.KvittCard
|
||||
@@ -56,10 +62,11 @@ fun SettingsHubScreen(
|
||||
onOpenAbout: () -> Unit,
|
||||
onNavigate: (KvittDestination) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenCards: () -> Unit = {},
|
||||
viewModel: SettingsHubViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
SettingsHubContent(state, SettingsHubActions(onOpenProfile, onOpenCategories, onOpenRecurring, onOpenDevices, onOpenInvites, onOpenSyncStatus, onOpenAbout, onNavigate), modifier)
|
||||
SettingsHubContent(state, SettingsHubActions(onOpenProfile, onOpenCategories, onOpenRecurring, onOpenDevices, onOpenInvites, onOpenSyncStatus, onOpenAbout, onNavigate, onOpenCards), modifier)
|
||||
}
|
||||
|
||||
/** The navigation of S-13. */
|
||||
@@ -72,6 +79,7 @@ class SettingsHubActions(
|
||||
val onOpenSyncStatus: () -> Unit = {},
|
||||
val onOpenAbout: () -> Unit = {},
|
||||
val onNavigate: (KvittDestination) -> Unit = {},
|
||||
val onOpenCards: () -> Unit = {},
|
||||
)
|
||||
|
||||
/** Stateless S-13; [state] null while the session loads (nothing but the bars). */
|
||||
@@ -93,14 +101,16 @@ fun SettingsHubContent(state: SettingsHubState?, actions: SettingsHubActions, mo
|
||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
|
||||
) {
|
||||
HouseholdCard(state)
|
||||
SegmentedGroup(3) { item ->
|
||||
SegmentedGroup(4) { item ->
|
||||
when (item.index) {
|
||||
0 -> SettingsRow(item, KvittIcon.Person, ColorToken.ORANGE, stringResource(R.string.settings_profile), actions.onOpenProfile, subtitle = stringResource(R.string.settings_profile_summary))
|
||||
1 -> SettingsRow(
|
||||
item, KvittIcon.Category, ColorToken.GREEN, stringResource(R.string.settings_categories), actions.onOpenCategories,
|
||||
subtitle = pluralStringResource(R.plurals.settings_categories_summary, state.activeCategories, state.activeCategories),
|
||||
)
|
||||
else -> SettingsRow(item, KvittIcon.EventRepeat, ColorToken.BROWN, stringResource(R.string.settings_recurring), actions.onOpenRecurring, subtitle = recurringSummary(state))
|
||||
2 -> SettingsRow(item, KvittIcon.EventRepeat, ColorToken.BROWN, stringResource(R.string.settings_recurring), actions.onOpenRecurring, subtitle = recurringSummary(state))
|
||||
// D-172: "Anslut kort" opens S-24.
|
||||
else -> SettingsRow(item, KvittIcon.CreditCard, ColorToken.INDIGO, stringResource(R.string.settings_cards), actions.onOpenCards, subtitle = cardsSummary(state))
|
||||
}
|
||||
}
|
||||
val rows = buildList {
|
||||
@@ -128,6 +138,17 @@ fun SettingsHubContent(state: SettingsHubState?, actions: SettingsHubActions, mo
|
||||
|
||||
private enum class HubRow { Devices, InvitePartner, PairDevice, Sync, About }
|
||||
|
||||
/** "Ansluten · giltig till <date>" (list form), "Har upphört" for an ended consent, else "Läs in dina kortköp". */
|
||||
@Composable
|
||||
private fun cardsSummary(state: SettingsHubState): String {
|
||||
val card = state.card ?: return stringResource(R.string.settings_cards_none)
|
||||
if (card.status == CardConnectionStatus.EXPIRED) return stringResource(R.string.settings_cards_expired)
|
||||
val until = card.validUntil ?: return stringResource(R.string.settings_cards_none)
|
||||
val clock = LocalKvittClock.current
|
||||
val date = DateFormatter.listDate(Instant.parse(until).toLocalDateTime(clock.zone).date, clock.today, kvittLanguage())
|
||||
return stringResource(R.string.settings_cards_connected, date)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun recurringSummary(state: SettingsHubState): String {
|
||||
if (state.activeTemplates == 0 && state.pausedTemplates == 0) return stringResource(R.string.settings_recurring_summary_none)
|
||||
|
||||
@@ -8,6 +8,9 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import se.hagstedt.kvitt.model.AboutRepository
|
||||
import se.hagstedt.kvitt.model.CardConnection
|
||||
import se.hagstedt.kvitt.model.CardFilter
|
||||
import se.hagstedt.kvitt.model.CardRepository
|
||||
import se.hagstedt.kvitt.model.Category
|
||||
import se.hagstedt.kvitt.model.CategoryRepository
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
@@ -26,7 +29,8 @@ import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* S-13 (design-spec §4 S-13): the household card, row subtitles and which rows the viewer gets. [partner] null: the
|
||||
* partner has not joined (AC-5.1). The invite row is the admin's only (AC-2.1, AC-2.2, §8.4).
|
||||
* partner has not joined (AC-5.1). The invite row is the admin's only (AC-2.1, AC-2.2, §8.4). [card]: the viewer's card
|
||||
* connection (ACTIVE or EXPIRED) for the "Anslut kort" subtitle (D-172), null without one.
|
||||
*/
|
||||
data class SettingsHubState(
|
||||
val me: User,
|
||||
@@ -38,6 +42,7 @@ data class SettingsHubState(
|
||||
val online: Boolean,
|
||||
val versionName: String,
|
||||
val updateAvailable: Boolean,
|
||||
val card: CardConnection? = null,
|
||||
) {
|
||||
val isAdmin: Boolean get() = me.role == Role.ADMIN
|
||||
|
||||
@@ -61,6 +66,7 @@ fun settingsHubState(
|
||||
online: Boolean,
|
||||
versionName: String,
|
||||
update: UpdateState,
|
||||
card: CardConnection? = null,
|
||||
): SettingsHubState? {
|
||||
val paired = session as? SessionState.Paired ?: return null
|
||||
val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me
|
||||
@@ -75,6 +81,7 @@ fun settingsHubState(
|
||||
online = online,
|
||||
versionName = versionName,
|
||||
updateAvailable = update !is UpdateState.None,
|
||||
card = card,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,13 +95,14 @@ class SettingsHubViewModel @Inject constructor(
|
||||
about: AboutRepository,
|
||||
update: UpdateRepository,
|
||||
connectivity: ConnectivityRepository,
|
||||
cards: CardRepository,
|
||||
) : ViewModel() {
|
||||
private val people = combine(session.session, profile.household()) { s, h -> s to h }
|
||||
private val lists = combine(categories.all(), recurring.all()) { c, t -> c to t }
|
||||
private val status = combine(sync.status(), connectivity.online, about.about(), update.state) { s, o, a, u -> Status(s.summary, o, a.versionName, u) }
|
||||
|
||||
val state: StateFlow<SettingsHubState?> = combine(people, lists, status) { (s, h), (c, t), st ->
|
||||
settingsHubState(s, h, c, t, st.sync, st.online, st.versionName, st.update)
|
||||
val state: StateFlow<SettingsHubState?> = combine(people, lists, status, cards.cards(CardFilter.ALL)) { (s, h), (c, t), st, card ->
|
||||
settingsHubState(s, h, c, t, st.sync, st.online, st.versionName, st.update, card.connection)
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
private data class Status(val sync: SyncSummary, val online: Boolean, val versionName: String, val update: UpdateState)
|
||||
|
||||
@@ -180,4 +180,12 @@
|
||||
<string name="settings_expanded">utfälld</string>
|
||||
<string name="settings_collapsed">hopfälld</string>
|
||||
<string name="settings_partner_missing">Partner saknas</string>
|
||||
<!-- S-13 row (D-172), opens S-24 -->
|
||||
<string name="settings_cards">Anslut kort</string>
|
||||
<!-- S-13 row subtitle; %1$s the consent\'s end date (§9.8 list form) -->
|
||||
<string name="settings_cards_connected">Ansluten · giltig till %1$s</string>
|
||||
<!-- S-13 row subtitle without a connection -->
|
||||
<string name="settings_cards_none">Läs in dina kortköp</string>
|
||||
<!-- S-13 row subtitle of an expired connection (CP-6); K2 addition -->
|
||||
<string name="settings_cards_expired">Har upphört</string>
|
||||
</resources>
|
||||
|
||||
@@ -180,4 +180,12 @@
|
||||
<string name="settings_expanded">expanded</string>
|
||||
<string name="settings_collapsed">collapsed</string>
|
||||
<string name="settings_partner_missing">Partner has not joined</string>
|
||||
<!-- S-13 row (D-172), opens S-24 -->
|
||||
<string name="settings_cards">Connect card</string>
|
||||
<!-- S-13 row subtitle; %1$s the consent\'s end date (§9.8 list form) -->
|
||||
<string name="settings_cards_connected">Connected · valid until %1$s</string>
|
||||
<!-- S-13 row subtitle without a connection -->
|
||||
<string name="settings_cards_none">Read your card purchases</string>
|
||||
<!-- S-13 row subtitle of an expired connection (CP-6); K2 addition -->
|
||||
<string name="settings_cards_expired">Ended</string>
|
||||
</resources>
|
||||
|
||||
@@ -37,7 +37,7 @@ class StateHolderTest {
|
||||
val main = MainDispatcherRule()
|
||||
|
||||
private fun hub(r: ScriptedSettingsRepositories) =
|
||||
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity)
|
||||
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity, r.fakes.cards)
|
||||
|
||||
@Test
|
||||
fun hubCountsAndRows() {
|
||||
|
||||
@@ -9,7 +9,9 @@ import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import se.hagstedt.kvitt.fakes.FakeCards
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.cards.CardScenarios
|
||||
import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories
|
||||
import se.hagstedt.kvitt.fakes.settings.SettingsScenarios
|
||||
import se.hagstedt.kvitt.feature.settings.hub.SettingsHubActions
|
||||
@@ -29,7 +31,7 @@ class HubScreenshotTest(private val state: String) {
|
||||
|
||||
companion object {
|
||||
fun viewModel(r: ScriptedSettingsRepositories) =
|
||||
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity)
|
||||
SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity, r.fakes.cards)
|
||||
|
||||
private fun shot(name: String, scenario: FakeScenario) = Shot(name) { compose ->
|
||||
val vm = viewModel(ScriptedSettingsRepositories(scenario))
|
||||
@@ -46,6 +48,8 @@ class HubScreenshotTest(private val state: String) {
|
||||
shot("offline", SettingsScenarios.offline),
|
||||
shot("update_available", SettingsScenarios.updateAvailable),
|
||||
shot("long", SettingsScenarios.long),
|
||||
// D-172: "Anslut kort" with a connection: "Ansluten · giltig till 22 apr. 2027".
|
||||
shot("card_connected", SettingsScenarios.loaded.copy(cards = FakeCards(connection = CardScenarios.connection))),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 37 KiB |