K2: card purchases on Android (Room 2, card cycle, :feature:cards S-23 to S-27)
- :core:model model/Cards.kt per SPEC §14.2.4 "Changes in v0.35"; ContractTest covers the card wire values. - :domain matchCardPurchase (§14.5.8), passing match_card_purchase.json (17 cases) in VectorTest. - :data Room version 2 (card_connections, card_purchases, card_sync_state, MIGRATION_1_2 with its test, 2.json exported); DTOs for E-36 to E-44 round-tripping K1's examples; the card cycle (E-44 push of pending link changes, E-40 pull) after each household cycle and after refresh; CardRepositoryImpl with CP-9 prefill, CP-10 matches, offline-first link/unlink/ignore; a local expense delete unlinks. - :core:fakes FakeCardRepository and fakes/cards scenarios for every state of S-23 to S-27. - :feature:cards S-23 (sheet and "Välj en annan utgift"), S-24, S-25 (Custom Tab, E-38 every 2 s while resumed, return link), S-26 (disconnect dialog), S-27, CardArt with the SJ Prio picture and the drawn fallback; 135 goldens and state-holder tests. - :feature:expense S-7 cardPurchaseId prefill and link on save, S-8 "Från kortköp" line; :feature:account S-5 "Från kortköp"; :feature:settings S-13 "Anslut kort". - :core:design KvittDestination.Cards with credit_card and nav_cards (four destinations, D-172); the bar's labels shrink to fit at large font scales. :app routes, kvitt://cards/connected, bindings. - docs/ux/strings.csv: the card keys of §13.3 plus K2's additions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+13
-1
@@ -71,6 +71,7 @@ data class AccountDetailActions(
|
||||
val onRetry: (AccountAction) -> Unit = {},
|
||||
val onMessageShown: () -> Unit = {},
|
||||
val onRefresh: () -> Unit = {},
|
||||
val onFromCardPurchases: (AccountId) -> Unit = {},
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -90,6 +91,7 @@ fun AccountDetailScreen(
|
||||
onArchived: (AccountId, String) -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onFromCardPurchases: (AccountId) -> Unit = {},
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
viewModel: AccountDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
@@ -123,6 +125,7 @@ fun AccountDetailScreen(
|
||||
onRetry = viewModel::retry,
|
||||
onMessageShown = viewModel::messageShown,
|
||||
onRefresh = viewModel::refresh,
|
||||
onFromCardPurchases = onFromCardPurchases,
|
||||
)
|
||||
AccountDetailContent(ui, actions, modifier, snackbarHostState = snackbarHostState)
|
||||
}
|
||||
@@ -242,7 +245,16 @@ private fun OverflowMenu(ui: AccountDetailUi.Ready, actions: AccountDetailAction
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
KvittMenuContent(
|
||||
listOf(
|
||||
listOfNotNull(
|
||||
// AC-22.9: "Från kortköp" while the viewer has a card connection; local, so offline too (CP-9).
|
||||
if (ui.hasCardConnection && !archived) {
|
||||
KvittMenuItem(KvittIcon.CreditCard, stringResource(R.string.account_from_card_purchases)) {
|
||||
actions.onMenu(false)
|
||||
actions.onFromCardPurchases(ui.detail.account.id)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
},
|
||||
KvittMenuItem(KvittIcon.Edit, stringResource(R.string.account_edit), ui.canEdit, offlineReason) {
|
||||
actions.onMenu(false)
|
||||
actions.onEdit(ui.detail.account.id)
|
||||
|
||||
+9
-2
@@ -16,6 +16,7 @@ import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
|
||||
import se.hagstedt.kvitt.model.AccountDetailState
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.AccountRepository
|
||||
import se.hagstedt.kvitt.model.CardRepository
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SyncRepository
|
||||
@@ -59,7 +60,10 @@ sealed interface AccountEvent {
|
||||
sealed interface AccountDetailUi {
|
||||
data object Starting : AccountDetailUi
|
||||
|
||||
/** [refreshing]: a pull-to-refresh sync is running; the indicator stays until it ends (D-127). */
|
||||
/**
|
||||
* [refreshing]: a pull-to-refresh sync is running; the indicator stays until it ends (D-127). [hasCardConnection]:
|
||||
* the viewer has a card connection, so the menu offers "Från kortköp" (S-27, AC-22.9).
|
||||
*/
|
||||
data class Ready(
|
||||
val detail: AccountDetailState,
|
||||
val online: Boolean,
|
||||
@@ -70,6 +74,7 @@ sealed interface AccountDetailUi {
|
||||
val message: AccountMessage? = null,
|
||||
val event: AccountEvent? = null,
|
||||
val refreshing: Boolean = false,
|
||||
val hasCardConnection: Boolean = false,
|
||||
) : AccountDetailUi {
|
||||
val partner: User get() = detail.balance.partner
|
||||
val me: User get() = detail.users.firstOrNull { it.id != partner.id } ?: partner
|
||||
@@ -107,11 +112,12 @@ class AccountDetailViewModel @Inject constructor(
|
||||
private val accounts: AccountRepository,
|
||||
private val connectivity: ConnectivityRepository,
|
||||
private val sync: SyncRepository,
|
||||
cards: CardRepository,
|
||||
) : ViewModel() {
|
||||
val id = AccountId(checkNotNull(savedState.get<String>(ARG_ACCOUNT_ID)) { "missing $ARG_ACCOUNT_ID" })
|
||||
private val local = MutableStateFlow(AccountLocal())
|
||||
|
||||
val ui: StateFlow<AccountDetailUi> = combine(accounts.detail(id), accounts.all(), connectivity.online, local) { detail, all, online, l ->
|
||||
val ui: StateFlow<AccountDetailUi> = combine(accounts.detail(id), accounts.all(), connectivity.online, local, cards.hasConnection) { detail, all, online, l, card ->
|
||||
if (detail == null) {
|
||||
AccountDetailUi.Gone(l.event ?: AccountEvent.Gone)
|
||||
} else {
|
||||
@@ -125,6 +131,7 @@ class AccountDetailViewModel @Inject constructor(
|
||||
message = l.message,
|
||||
event = l.event,
|
||||
refreshing = l.refreshing,
|
||||
hasCardConnection = card,
|
||||
)
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountDetailUi.Starting)
|
||||
|
||||
@@ -31,4 +31,6 @@
|
||||
<string name="account_editor_offline_body_edit">Konton sparas direkt på servern. Anslut telefonen till internet för att spara ändringen.</string>
|
||||
<!-- name_taken -->
|
||||
<string name="account_editor_name_taken">Det finns redan ett konto som heter %1$s</string>
|
||||
<!-- S-5 overflow item while the viewer has a connection (AC-22.9), opens S-27 -->
|
||||
<string name="account_from_card_purchases">Från kortköp</string>
|
||||
</resources>
|
||||
|
||||
@@ -31,4 +31,6 @@
|
||||
<string name="account_editor_offline_body_edit">Accounts are saved straight to the server. Connect the phone to the internet to save the change.</string>
|
||||
<!-- name_taken -->
|
||||
<string name="account_editor_name_taken">There is already an account called %1$s</string>
|
||||
<!-- S-5 overflow item while the viewer has a connection (AC-22.9), opens S-27 -->
|
||||
<string name="account_from_card_purchases">From card purchases</string>
|
||||
</resources>
|
||||
|
||||
+5
-1
@@ -9,7 +9,9 @@ import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
|
||||
import se.hagstedt.kvitt.fakes.FakeCards
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.cards.CardScenarios
|
||||
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.design.testing.ScreenSnapshots
|
||||
@@ -25,6 +27,8 @@ val accountStates: List<ScreenState> = listOf(
|
||||
detail("S-5-account-negative", AccountScenarios.loaded, AccountScenarios.huset),
|
||||
detail("S-5-account-archived", AccountScenarios.loaded, AccountScenarios.gotland),
|
||||
detail("S-5-account-menu", AccountScenarios.loaded, AccountScenarios.vardag) { it.setMenuOpen(true) },
|
||||
// AC-22.9: with a card connection the menu starts with "Från kortköp".
|
||||
detail("S-5-account-menu-card", AccountScenarios.loaded.copy(cards = FakeCards(connection = CardScenarios.connection)), AccountScenarios.vardag) { it.setMenuOpen(true) },
|
||||
detail("S-5-account-menu-deletable", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.setMenuOpen(true) },
|
||||
detail("S-5-account-delete-confirm", AccountScenarios.empty, AccountScenarios.sommarstugan.id) { it.askDelete() },
|
||||
detail("S-5-account-empty", AccountScenarios.empty, AccountScenarios.sommarstugan.id),
|
||||
@@ -85,7 +89,7 @@ class AccountScreenshotTest(private val state: String) {
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun states(): List<Array<Any>> = listOf(
|
||||
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-deletable",
|
||||
"S-5-account", "S-5-account-negative", "S-5-account-archived", "S-5-account-menu", "S-5-account-menu-card", "S-5-account-menu-deletable",
|
||||
"S-5-account-delete-confirm", "S-5-account-empty", "S-5-account-offline", "S-5-account-error", "S-5-account-long",
|
||||
"S-5-account-refreshing",
|
||||
"S-6-account-editor-create", "S-6-account-editor", "S-6-account-editor-edit", "S-6-account-editor-decimals",
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ fun args(id: AccountId?): SavedStateHandle = SavedStateHandle(if (id == null) em
|
||||
fun detailState(scenario: FakeScenario, id: AccountId, failWith: String? = null, act: (AccountDetailViewModel) -> Unit = {}): AccountDetailUi = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val accounts = if (failWith == null) fakes.accounts else FailingAccounts(fakes.accounts, failWith)
|
||||
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity, fakes.sync)
|
||||
val vm = AccountDetailViewModel(args(id), accounts, fakes.connectivity, fakes.sync, fakes.cards)
|
||||
act(vm)
|
||||
vm.ui.value
|
||||
}
|
||||
|
||||
+10
-1
@@ -6,7 +6,9 @@ import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import se.hagstedt.kvitt.design.format.FAILURE_NETWORK
|
||||
import se.hagstedt.kvitt.fakes.FakeCards
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.cards.CardScenarios
|
||||
import se.hagstedt.kvitt.fakes.FakeScenarios
|
||||
import se.hagstedt.kvitt.fakes.account.AccountScenarios
|
||||
import se.hagstedt.kvitt.model.AccountId
|
||||
@@ -105,11 +107,18 @@ class AccountDetailViewModelTest {
|
||||
assertEquals(AccountMessage.Failed("forbidden"), ui.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fromCardPurchasesIsOfferedOnlyWithACardConnection() {
|
||||
assertEquals(false, ready(detailState(AccountScenarios.loaded, AccountScenarios.vardag)).hasCardConnection)
|
||||
val connected = AccountScenarios.loaded.copy(cards = FakeCards(connection = CardScenarios.connection))
|
||||
assertEquals(true, ready(detailState(connected, AccountScenarios.vardag)).hasCardConnection)
|
||||
}
|
||||
|
||||
/** S-5 for Vardag in [scenario] whose sync waits for the test (pull-to-refresh, D-127); runs [block] with Main set. */
|
||||
private fun withGatedSync(scenario: FakeScenario, block: (AccountDetailViewModel, GatedSyncRepository) -> Unit) = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val sync = GatedSyncRepository(fakes.sync)
|
||||
block(AccountDetailViewModel(args(AccountScenarios.vardag), fakes.accounts, fakes.connectivity, sync), sync)
|
||||
block(AccountDetailViewModel(args(AccountScenarios.vardag), fakes.accounts, fakes.connectivity, sync, fakes.cards), sync)
|
||||
}
|
||||
|
||||
private fun AccountDetailUi.Ready.pendingRows(): Int = detail.months.flatMap { it.rows }.count {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
Reference in New Issue
Block a user