diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/expense/ExpenseScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/expense/ExpenseScenarios.kt new file mode 100644 index 0000000..35a1e6f --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/expense/ExpenseScenarios.kt @@ -0,0 +1,266 @@ +package se.hagstedt.kvitt.fakes.expense + +import se.hagstedt.kvitt.fakes.FakeExpense +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.FakeScenarios +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.AuditSource +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.ExpenseDraft +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.FieldChange +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.TimelineLine +import se.hagstedt.kvitt.model.UserId + +/** + * Scenarios of `:feature:expense` (S-7 expense editor, S-8 expense detail; PLAN-M7c §5), as in the design spec's + * frames: Jacob (the viewer) and Anna, the accounts Vardag (50/50), Huset (60/40), Japan 2027 and the archived Gotland + * 2026, and the expenses the frames show. The phone's clock is 17 October 2026 14:02 in Stockholm. + */ +object ExpenseScenarios { + private val jacob = FakeScenarios.owner + private val anna = FakeScenarios.partner + + private fun category(key: DefaultCategoryKey): Category = FakeScenarios.defaultCategories.single { it.defaultKey == key } + + val groceries = category(DefaultCategoryKey.GROCERIES) + val restaurants = category(DefaultCategoryKey.RESTAURANTS) + val housing = category(DefaultCategoryKey.HOUSING) + val transport = category(DefaultCategoryKey.TRANSPORT) + val entertainment = category(DefaultCategoryKey.ENTERTAINMENT) + val shopping = category(DefaultCategoryKey.SHOPPING) + val other = category(DefaultCategoryKey.OTHER) + + private fun shares(jacobBp: Int) = mapOf(jacob.id to jacobBp, anna.id to 10_000 - jacobBp) + + val vardag = Account(AccountId("01920000-0000-7000-8000-0000000000b1"), "Vardag", shares(5000), "2026-01-04T09:00:00.000Z", archived = false) + val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", shares(6000), "2026-01-05T09:00:00.000Z", archived = false) + val japan = Account(AccountId("01920000-0000-7000-8000-0000000000b3"), "Japan 2027", shares(5000), "2026-03-01T09:00:00.000Z", archived = false) + val gotland = Account(AccountId("01920000-0000-7000-8000-0000000000b4"), "Gotland 2026", shares(5000), "2026-05-20T09:00:00.000Z", archived = true) + val sommarstugan = Account( + AccountId("01920000-0000-7000-8000-0000000000b5"), + "Sommarstugan i Österåker 2027", + shares(6000), + "2026-10-01T09:00:00.000Z", + archived = false, + ) + + /** A custom category with a long name (long-content states). */ + val renovation = Category(CategoryId("01920000-0000-7000-8000-0000000000c2"), null, "Renovering av sommarstugan", "build", ColorToken.AMBER, 110, archived = false) + + val icaId = ExpenseId("01920000-0000-7000-8000-0000000000e1") + val systembolagetId = ExpenseId("01920000-0000-7000-8000-0000000000e2") + val slKortId = ExpenseId("01920000-0000-7000-8000-0000000000e3") + val hyraId = ExpenseId("01920000-0000-7000-8000-0000000000e4") + val ullaredId = ExpenseId("01920000-0000-7000-8000-0000000000e5") + val trananId = ExpenseId("01920000-0000-7000-8000-0000000000e6") + val snickareId = ExpenseId("01920000-0000-7000-8000-0000000000e7") + + /** An id the phone does not hold (S-8 missing). */ + val missingId = ExpenseId("01920000-0000-7000-8000-0000000000ef") + + private val hyraTemplate = TemplateId("01920000-0000-7000-8000-0000000000f1") + + private fun created(by: UserId?, at: String, source: AuditSource = AuditSource.APP) = + TimelineLine(if (by == null) null else if (by == jacob.id) jacob else anna, AuditAction.CREATE, source, at, emptyList(), pendingLocal = false) + + private fun changed(by: UserId, at: String, vararg changes: FieldChange) = + TimelineLine(if (by == jacob.id) jacob else anna, AuditAction.UPDATE, AuditSource.APP, at, changes.toList(), pendingLocal = false) + + private fun even(amount: Long) = listOf(Share(jacob.id, amount / 2, 5000), Share(anna.id, amount - amount / 2, 5000)) + + /** S-8 loaded + pending: Anna paid, account default 50/50, notes, created and changed twice, one change pending. */ + val ica = FakeExpense( + id = icaId, + accountId = vardag.id, + categoryId = groceries.id, + amountOre = 124_760, + description = "ICA Maxi Lindhagen", + notes = "Storhandling inför middagen med Lindqvists", + date = "2026-10-16", + payer = anna.id, + mode = SplitMode.DEFAULT, + shares = even(124_760), + createdAt = "2026-10-16T12:02:00.000Z", + pending = true, + timeline = listOf( + created(anna.id, "2026-10-16T12:02:00.000Z"), + changed(jacob.id, "2026-10-16T17:40:00.000Z", FieldChange("amount_ore", "114760", "124760")), + changed(anna.id, "2026-10-16T18:05:00.000Z", FieldChange("category_id", "other", "groceries")), + ), + ) + + /** History for the category suggestion: "Systembolaget" was Nöje before (CS-5 history exact). */ + val systembolaget = FakeExpense( + id = systembolagetId, + accountId = vardag.id, + categoryId = entertainment.id, + amountOre = 41_200, + description = "Systembolaget", + notes = "", + date = "2026-10-10", + payer = jacob.id, + mode = SplitMode.DEFAULT, + shares = even(41_200), + createdAt = "2026-10-10T15:20:00.000Z", + timeline = listOf(created(jacob.id, "2026-10-10T15:20:00.000Z")), + ) + + /** Full split on Anna, paid by Jacob (S-7 full + notes in edit mode, S-8 full). */ + val slKort = FakeExpense( + id = slKortId, + accountId = vardag.id, + categoryId = transport.id, + amountOre = 106_000, + description = "SL-kort 30 dagar, Anna", + notes = "Månadskort för oktober, köpt i SL-appen", + date = "2026-10-14", + payer = jacob.id, + mode = SplitMode.FULL, + shares = listOf(Share(jacob.id, 0, 0), Share(anna.id, 106_000, 10_000)), + createdAt = "2026-10-14T07:30:00.000Z", + timeline = listOf(created(jacob.id, "2026-10-14T07:30:00.000Z")), + ) + + /** Generated by the template "Hyra" (AC-8.11), later edited by Anna. */ + val hyra = FakeExpense( + id = hyraId, + accountId = huset.id, + categoryId = housing.id, + amountOre = 1_450_000, + description = "Hyra september", + notes = "", + date = "2026-09-25", + payer = jacob.id, + mode = SplitMode.DEFAULT, + shares = listOf(Share(jacob.id, 870_000, 6000), Share(anna.id, 580_000, 4000)), + createdAt = "2026-09-25T04:00:00.000Z", + recurringTemplateId = hyraTemplate, + timeline = listOf( + created(null, "2026-09-25T04:00:00.000Z", AuditSource.RECURRING), + changed(anna.id, "2026-09-26T08:15:00.000Z", FieldChange("description", "Hyra", "Hyra september")), + ), + ) + + /** Exact amounts: Jacob 400,00 kr, Anna 660,00 kr; Anna paid. */ + val ullared = FakeExpense( + id = ullaredId, + accountId = vardag.id, + categoryId = shopping.id, + amountOre = 106_000, + description = "Ullared", + notes = "", + date = "2026-10-03", + payer = anna.id, + mode = SplitMode.EXACT, + shares = listOf(Share(jacob.id, 40_000, null), Share(anna.id, 66_000, null)), + createdAt = "2026-10-03T16:45:00.000Z", + timeline = listOf(created(anna.id, "2026-10-03T16:45:00.000Z")), + ) + + /** Custom ratio 70/30, moved from Vardag to Huset; the last change is waiting to sync (S-8 timeline). */ + val tranan = FakeExpense( + id = trananId, + accountId = huset.id, + categoryId = restaurants.id, + amountOre = 156_000, + description = "Middag på Tranan", + notes = "", + date = "2026-10-12", + payer = jacob.id, + mode = SplitMode.RATIO, + shares = listOf(Share(jacob.id, 109_200, 7000), Share(anna.id, 46_800, 3000)), + createdAt = "2026-10-12T19:10:00.000Z", + pending = true, + timeline = listOf( + created(jacob.id, "2026-10-12T19:10:00.000Z"), + changed( + anna.id, + "2026-10-13T07:45:00.000Z", + FieldChange("amount_ore", "120000", "156000"), + FieldChange("account_id", "Vardag", "Huset"), + ), + changed(jacob.id, "2026-10-13T20:30:00.000Z", FieldChange("split_mode", "default", "ratio")), + ), + ) + + /** Long content: a 7-digit amount, a long description, account and category name, and a long history. */ + val snickare = FakeExpense( + id = snickareId, + accountId = sommarstugan.id, + categoryId = renovation.id, + amountOre = 123_456_789, + description = "Snickare Bergström, nytt golv i hallen, köket och vardagsrummet", + notes = "Offert 2026-08-30. Andra delbetalningen av tre; den sista betalas när besiktningen är klar i november.", + date = "2026-10-02", + payer = anna.id, + mode = SplitMode.DEFAULT, + shares = listOf(Share(jacob.id, 74_074_073, 6000), Share(anna.id, 49_382_716, 4000)), + createdAt = "2026-10-02T10:00:00.000Z", + pending = true, + timeline = listOf( + created(anna.id, "2026-10-02T10:00:00.000Z"), + changed(jacob.id, "2026-10-02T18:30:00.000Z", FieldChange("amount_ore", "120000000", "123456789")), + changed(anna.id, "2026-10-03T07:05:00.000Z", FieldChange("description", "Snickare", "Snickare Bergström, nytt golv i hallen, köket och vardagsrummet")), + changed(anna.id, "2026-10-03T07:06:00.000Z", FieldChange("category_id", "home", "Renovering av sommarstugan")), + changed(jacob.id, "2026-10-04T21:15:00.000Z", FieldChange("expense_date", "2026-10-01", "2026-10-02"), FieldChange("payer_user_id", "Jacob", "Anna")), + changed(anna.id, "2026-10-05T08:00:00.000Z", FieldChange("notes", null, "Offert 2026-08-30. Andra delbetalningen av tre; den sista betalas när besiktningen är klar i november.")), + changed(jacob.id, "2026-10-06T12:40:00.000Z", FieldChange("account_id", "Huset", "Sommarstugan i Österåker 2027")), + ), + ) + + private val hyraDraft = ExpenseDraft(null, huset.id, housing.id, 1_450_000, "Hyra", "", "2026-09-25", jacob.id, SplitMode.DEFAULT, null, null, null) + + /** Loaded, online and synced except the pending rows; Vardag was the last account used, groceries its last category. */ + val household: FakeScenario = FakeScenarios.empty.copy( + accounts = listOf(vardag, huset, japan, gotland), + expenses = listOf(ica, systembolaget, slKort, hyra, ullared, tranan), + templates = listOf(RecurringTemplate(hyraTemplate, hyraDraft, 25, "2026-01", null, paused = false, nextOccurrence = "2026-10-25")), + now = "2026-10-17T12:02:00.000Z", + defaultAccountId = vardag.id, + lastCategoryFor = mapOf(vardag.id to groceries.id, huset.id to housing.id), + ) + + /** The same household without a connection (S-7 saving still allowed, S-8 unchanged). */ + val offline: FakeScenario = household.copy(online = false) + + /** Long names and amounts: the long account and category exist, the long expense is in the long account. */ + val long: FakeScenario = household.copy( + accounts = household.accounts + sommarstugan, + categories = household.categories + renovation, + expenses = household.expenses + snickare, + defaultAccountId = sommarstugan.id, + lastCategoryFor = household.lastCategoryFor + (sommarstugan.id to renovation.id), + ) + + /** ICA Maxi deleted by Anna on 17 October 09:12 (S-8 deleted, opened from a deletion notification). */ + val deleted: FakeScenario = household.copy( + expenses = household.expenses.map { e -> + if (e.id != icaId) e else e.copy( + deleted = true, + pending = false, + timeline = e.timeline + TimelineLine(anna, AuditAction.DELETE, AuditSource.APP, "2026-10-17T07:12:00.000Z", emptyList(), pendingLocal = false), + ) + }, + ) + + /** + * No category to fall back to (AC-8.6, v0.9): `other` is archived and nothing was used on this phone, so a new + * expense starts without a category until the user picks one. + */ + val noDefaultCategory: FakeScenario = household.copy( + categories = household.categories.map { if (it.id == other.id) it.copy(archived = true) else it }, + expenses = emptyList(), + templates = emptyList(), + lastCategoryFor = emptyMap(), + ) +} diff --git a/android/feature/expense/build.gradle.kts b/android/feature/expense/build.gradle.kts index 7cee3c2..1876e2d 100644 --- a/android/feature/expense/build.gradle.kts +++ b/android/feature/expense/build.gradle.kts @@ -1,4 +1,21 @@ // :feature:expense — Expense editor, expense detail and timeline (S-7, S-8; M7c). Convention: android/README.md. plugins { id("kvitt.android.feature") + alias(libs.plugins.roborazzi) +} + +roborazzi { + outputDir.set(file("src/test/screenshots")) +} + +dependencies { + // BackHandler: back with the keypad up hides it instead of closing the editor (design-spec §3.7). + implementation(libs.androidx.activity.compose) + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt new file mode 100644 index 0000000..ded84c5 --- /dev/null +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt @@ -0,0 +1,274 @@ +package se.hagstedt.kvitt.feature.expense + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.draw.alpha +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.core.design.R as DesignR +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.Banner +import se.hagstedt.kvitt.design.component.BannerKind +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.CategoryTile +import se.hagstedt.kvitt.design.component.EmptyState +import se.hagstedt.kvitt.design.component.FactRow +import se.hagstedt.kvitt.design.component.KvittBadge +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.LocalKvittClock +import se.hagstedt.kvitt.design.component.MoneyText +import se.hagstedt.kvitt.design.component.NeedsConnectionHint +import se.hagstedt.kvitt.design.component.PersonAvatar +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SplitBar +import se.hagstedt.kvitt.design.component.SplitPreview +import se.hagstedt.kvitt.design.component.SyncPendingMarker +import se.hagstedt.kvitt.design.component.Timeline +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.categoryName +import se.hagstedt.kvitt.design.component.defaultModeLabel +import se.hagstedt.kvitt.design.component.detailDate +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.personName +import se.hagstedt.kvitt.design.component.shareText +import se.hagstedt.kvitt.design.component.spokenMoney +import se.hagstedt.kvitt.design.component.splitModeLabel +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.ExpenseDetailState +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.User +import kotlin.time.Instant + +/** What S-8 can ask its state holder to do; [ExpenseDetailViewModel] implements it. */ +interface ExpenseDetailActions { + fun askDelete() + fun dismissDelete() + fun confirmDelete() + fun syncNow() +} + +/** + * S-8, the expense detail with its timeline (design-spec §4 S-8; SPEC AC-8.11, AC-12.4, AC-12.5). [onEdit] opens S-7 + * in edit mode; [onDeleted] goes back with the snackbar text "Utgiften togs bort". + */ +@Composable +fun ExpenseDetailScreen( + onBack: () -> Unit, + onEdit: (ExpenseId) -> Unit, + onDeleted: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: ExpenseDetailViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val deleted = stringResource(DesignR.string.snackbar_expense_deleted) + LaunchedEffect(viewModel) { + viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted(deleted) } + } + ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier) +} + +/** The stateless S-8 layout. */ +@Composable +fun ExpenseDetailContent( + state: ExpenseDetailUiState, + actions: ExpenseDetailActions, + onBack: () -> Unit, + onEdit: (ExpenseId) -> Unit, + modifier: Modifier = Modifier, +) { + val detail = state.detail + val me = state.me + Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + if (me == null || state.loading) return@Surface + ProvideHousehold(me, state.partner) { + Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) { + KvittTopAppBar(title = "", navigation = TopBarNavigation.Back, onNavigation = onBack) { + if (detail != null && !detail.deleted) { + IconButton({ onEdit(detail.id) }) { KvittIconImage(KvittIcon.Edit, stringResource(R.string.expense_editor_edit)) } + IconButton(actions::askDelete) { KvittIconImage(KvittIcon.Delete, stringResource(R.string.expense_editor_delete)) } + } + } + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s6), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + if (detail == null) Missing(state, actions) else Loaded(detail, state) + } + } + if (state.confirmDelete) DeleteExpenseDialog(actions::confirmDelete, actions::dismissDelete) + } + } +} + +@Composable +private fun ColumnScope.Missing(state: ExpenseDetailUiState, actions: ExpenseDetailActions) { + EmptyState( + title = stringResource(R.string.expense_detail_missing), + body = stringResource(R.string.expense_detail_missing_body), + action = stringResource(R.string.expense_detail_sync_now), + actionIcon = KvittIcon.Sync, + actionKind = ButtonKind.Tonal, + onAction = { if (state.online && !state.syncing) actions.syncNow() }, + ) + if (!state.online) NeedsConnectionHint(Modifier.align(Alignment.CenterHorizontally)) +} + +@Composable +private fun Loaded(detail: ExpenseDetailState, state: ExpenseDetailUiState) { + val me = state.me ?: return + val partner = state.partner ?: return + if (detail.deleted) DeletedBanner(detail) + val faded = if (detail.deleted) Modifier.alpha(KvittDimens.DELETED_ALPHA) else Modifier + Hero(detail, faded) + val showNotes = detail.notes.isNotEmpty() + SegmentedGroup(if (showNotes) FACTS_WITH_NOTES else FACTS, faded) { item -> + when (item.index) { + 0 -> FactRow(item, KvittIcon.Event, stringResource(R.string.expense_detail_date), detailDate(detail.date)) + 1 -> FactRow( + item, + KvittIcon.Person, + stringResource(R.string.expense_detail_paid_by), + personName(detail.payer), + valueLeading = { PersonAvatar(detail.payer, KvittDimens.avatarChip) }, + ) + 2 -> { + val mode = modeLabel(detail, me, partner) + val shares = shareText(detail.shares, me, partner, spoken = true) + FactRow( + item, + KvittIcon.CallSplit, + stringResource(R.string.expense_detail_split), + mode, + spoken = stringResource(R.string.expense_detail_split) + ": " + mode + stringResource(DesignR.string.common_separator_comma) + shares, + extra = { + Column(Modifier.padding(top = KvittTheme.spacing.s2), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + SplitBar(viewerPercent(detail, me), thin = true) + SplitPreview(detail.shares, me, partner, showBar = false) + } + }, + ) + } + else -> FactRow(item, KvittIcon.Notes, stringResource(R.string.expense_detail_notes), detail.notes) + } + } + Timeline(detail.timeline) +} + +/** Hero card: category tile 60, description over "Mat & hushåll · Vardag", amount with the pending marker, recurring badge. */ +@Composable +private fun Hero(detail: ExpenseDetailState, modifier: Modifier) { + val categoryLabel = categoryName(detail.category) + val subtitle = categoryLabel + stringResource(DesignR.string.common_separator_dot) + detail.account.name + val spoken = listOfNotNull( + detail.description, + categoryLabel, + detail.account.name, + spokenMoney(detail.amountOre), + if (detail.pending) stringResource(DesignR.string.common_pending_sync).lowercase() else null, + detail.recurringTemplateName?.let { stringResource(R.string.expense_detail_recurring, it) }, + ).joinToString(stringResource(DesignR.string.common_separator_comma)) + Column( + modifier + .fillMaxWidth() + .background(KvittTheme.colors.categoryContainer(detail.category.color), KvittTheme.shapes.card) + .clearAndSetSemantics { contentDescription = spoken } + .padding(KvittTheme.spacing.s5), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s4)) { + CategoryTile(detail.category, KvittDimens.tileHero, archived = detail.deleted || detail.category.archived) + Column(Modifier.weight(1f)) { + Text(detail.description, style = MaterialTheme.typography.titleLarge) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + MoneyText(detail.amountOre, AmountStyle.Large, AmountTone.Neutral) + if (detail.pending) SyncPendingMarker(size = KvittDimens.iconButton, standalone = false) + } + detail.recurringTemplateName?.let { + KvittBadge(stringResource(R.string.expense_detail_recurring, it), kind = BadgeKind.Lantern, icon = KvittIcon.EventRepeat) + } + } +} + +/** "Anna tog bort den här utgiften · 17 okt. 09:12" from the timeline's delete entry (design-spec S-8 deleted). */ +@Composable +private fun DeletedBanner(detail: ExpenseDetailState) { + val entry = detail.timeline.lastOrNull { it.action == AuditAction.DELETE && !it.pendingLocal } + val clock = LocalKvittClock.current + val language = kvittLanguage() + val actor = entry?.actor?.let { personName(it) } ?: personName(detail.payer) + val time = entry?.let { DateFormatter.listDateTime(Instant.parse(it.clientAt), clock.zone, clock.today, language) } + val title = if (time == null) { + stringResource(DesignR.string.timeline_deleted, actor) + } else { + stringResource(R.string.expense_detail_deleted_banner, actor, time) + } + Banner(BannerKind.Neutral, KvittIcon.Delete, title, body = stringResource(R.string.expense_detail_deleted_body), iconTint = MaterialTheme.colorScheme.error) +} + +/** "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna" (design-spec S-8). */ +@Composable +private fun modeLabel(detail: ExpenseDetailState, me: User, partner: User): String { + val language = kvittLanguage() + val mineBp = detail.shares.firstOrNull { it.userId == me.id }?.shareBp + val ratio = mineBp?.let { stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) } + return when (detail.mode) { + SplitMode.DEFAULT -> if (ratio != null) stringResource(DesignR.string.editor_split_mode_default, ratio) else defaultModeLabel(HALF_PERCENT) + SplitMode.RATIO -> splitModeLabel(SplitMode.RATIO, "") + (ratio?.let { " $it" } ?: "") + SplitMode.EXACT -> splitModeLabel(SplitMode.EXACT, "") + SplitMode.FULL -> { + val bearer = detail.shares.firstOrNull { it.shareOre > 0 }?.userId + if (bearer == me.id) stringResource(R.string.expense_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName) + } + } +} + +private fun viewerPercent(detail: ExpenseDetailState, me: User): Float { + val total = detail.shares.sumOf { it.shareOre } + val mine = detail.shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L + return if (total == 0L) HALF_PERCENT.toFloat() else mine * FULL_PERCENT / total +} + +private const val FACTS = 3 +private const val FACTS_WITH_NOTES = 4 +private const val BP_TOTAL = 10_000 +private const val HALF_PERCENT = 50 +private const val FULL_PERCENT = 100f diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModel.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModel.kt new file mode 100644 index 0000000..cb2cba0 --- /dev/null +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModel.kt @@ -0,0 +1,107 @@ +package se.hagstedt.kvitt.feature.expense + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.flowOf +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.ExpenseDetailState +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.ExpenseRepository +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.SyncRepository +import se.hagstedt.kvitt.model.User +import javax.inject.Inject + +/** + * What S-8 renders. [detail] null after loading is the missing state (the row is not on the phone yet); a deleted row + * shows the deleted state (AC-8.11, design-spec S-8). + */ +data class ExpenseDetailUiState( + val loading: Boolean = true, + val detail: ExpenseDetailState? = null, + val me: User? = null, + val partner: User? = null, + val online: Boolean = true, + val confirmDelete: Boolean = false, + val syncing: Boolean = false, +) + +/** One-off results of S-8. */ +sealed interface DetailEvent { + data object Deleted : DetailEvent +} + +/** S-8 state holder. Argument: [ARG_EXPENSE_ID]. */ +@HiltViewModel +class ExpenseDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val expenses: ExpenseRepository, + home: HomeRepository, + connectivity: ConnectivityRepository, + private val sync: SyncRepository, +) : ViewModel(), ExpenseDetailActions { + private val id: ExpenseId? = savedStateHandle.get(ARG_EXPENSE_ID)?.let(::ExpenseId) + private val local = MutableStateFlow(Local()) + private val events = Channel(Channel.BUFFERED) + + private data class Local(val confirmDelete: Boolean = false, val syncing: Boolean = false) + + val eventFlow: Flow = events.receiveAsFlow() + + val state: StateFlow = combine( + id?.let { expenses.detail(it) } ?: flowOf(null), + home.home(), + connectivity.online, + local, + ) { detail, h, online, l -> + ExpenseDetailUiState( + loading = false, + detail = detail, + me = h.me, + partner = h.partner, + online = online, + confirmDelete = l.confirmDelete, + syncing = l.syncing, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, ExpenseDetailUiState()) + + override fun askDelete() = local.update { it.copy(confirmDelete = true) } + + override fun dismissDelete() = local.update { it.copy(confirmDelete = false) } + + /** AC-8.9: confirmed soft delete, then back with "Utgiften togs bort". */ + override fun confirmDelete() { + val target = id ?: return + local.update { it.copy(confirmDelete = false) } + viewModelScope.launch { + expenses.delete(target) + events.send(DetailEvent.Deleted) + } + } + + /** Missing state: "Synka nu"; the screen fills in when the row arrives. */ + override fun syncNow() { + local.update { it.copy(syncing = true) } + viewModelScope.launch { + sync.syncNow() + local.update { it.copy(syncing = false) } + } + } + + companion object { + /** Navigation argument: the expense to show. */ + const val ARG_EXPENSE_ID = "expenseId" + } +} diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt new file mode 100644 index 0000000..1d68ee9 --- /dev/null +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt @@ -0,0 +1,491 @@ +package se.hagstedt.kvitt.feature.expense + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.core.design.R as DesignR +import se.hagstedt.kvitt.design.component.AccountChip +import se.hagstedt.kvitt.design.component.AccountPicker +import se.hagstedt.kvitt.design.component.AmountDisplay +import se.hagstedt.kvitt.design.component.AmountKeypad +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.CategoryGrid +import se.hagstedt.kvitt.design.component.CategoryQuickRow +import se.hagstedt.kvitt.design.component.CompactEditorSummary +import se.hagstedt.kvitt.design.component.ConfirmDialog +import se.hagstedt.kvitt.design.component.ConfirmDialogContent +import se.hagstedt.kvitt.design.component.DateChip +import se.hagstedt.kvitt.design.component.DescriptionField +import se.hagstedt.kvitt.design.component.EditorChipRow +import se.hagstedt.kvitt.design.component.ExactSplitEditor +import se.hagstedt.kvitt.design.component.FullSplitPicker +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.NotesField +import se.hagstedt.kvitt.design.component.PayerChip +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.RatioEditor +import se.hagstedt.kvitt.design.component.RatioField +import se.hagstedt.kvitt.design.component.SplitBar +import se.hagstedt.kvitt.design.component.SplitChip +import se.hagstedt.kvitt.design.component.SplitPreview +import se.hagstedt.kvitt.design.component.SplitSection +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.defaultModeLabel +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.money +import se.hagstedt.kvitt.design.component.splitModeLabel +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.UserId + +/** What S-7 can ask its state holder to do; [ExpenseEditorViewModel] implements it. */ +interface ExpenseEditorActions { + fun onKey(key: AmountKey): Boolean + fun focusKeypad(target: KeypadTarget?) + fun onDescription(text: String) + fun onNotes(text: String) + fun expandNotes() + fun onCategory(id: CategoryId) + fun onAccount(id: AccountId) + fun onDate(iso: String) + fun togglePayer() + fun toggleSplit() + fun onMode(mode: SplitMode) + fun onViewerPercent(percent: Int) + fun onExactTyped(user: UserId) + fun onFullOn(user: UserId) + fun showOverlay(overlay: EditorOverlay) + fun dismissOverlay() + fun close() + fun back() + fun discard() + fun confirmDelete() + fun save() +} + +/** + * S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar text for + * the opener ("Utgiften sparades" / offline wording for a new expense, null after an edit: back to S-8 without one); + * [onDeleted] gets "Utgiften togs bort" and goes back past S-8 to its opener. + */ +@Composable +fun ExpenseEditorScreen( + onClose: () -> Unit, + onSaved: (ExpenseId, String?) -> Unit, + onDeleted: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: ExpenseEditorViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val descriptionFocus = remember { FocusRequester() } + val saved = stringResource(DesignR.string.snackbar_expense_saved) + val savedOffline = stringResource(DesignR.string.snackbar_expense_saved_offline) + val deleted = stringResource(DesignR.string.snackbar_expense_deleted) + LaunchedEffect(viewModel) { + viewModel.eventFlow.collect { event -> + when (event) { + is EditorEvent.Saved -> onSaved(event.id, if (!event.isNew) null else if (event.offline) savedOffline else saved) + EditorEvent.Deleted -> onDeleted(deleted) + EditorEvent.Closed -> onClose() + EditorEvent.FocusDescription -> descriptionFocus.requestFocus() + } + } + } + LaunchedEffect(state.missing) { if (state.missing) onClose() } + BackHandler { viewModel.back() } + ExpenseEditorContent(state, viewModel, modifier, descriptionFocus) +} + +/** The stateless S-7 layout: app bar, scrolling fields, the keypad, and the overlay of [ExpenseEditorState.overlay]. */ +@Composable +fun ExpenseEditorContent( + state: ExpenseEditorState, + actions: ExpenseEditorActions, + modifier: Modifier = Modifier, + descriptionFocus: FocusRequester = remember { FocusRequester() }, +) { + val me = state.me + val partner = state.partner + val form = state.form + Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + if (me == null || partner == null || form == null) return@Surface + ProvideHousehold(me, partner) { + EditorBody(state, actions, descriptionFocus) + EditorOverlays(state, actions) + } + } +} + +@Composable +private fun EditorBody(state: ExpenseEditorState, actions: ExpenseEditorActions, descriptionFocus: FocusRequester) { + val me = state.me ?: return + val partner = state.partner ?: return + val form = state.form ?: return + val language = kvittLanguage() + val focusManager = LocalFocusManager.current + val haptics = LocalHapticFeedback.current + val scroll = rememberScrollState() + val splitRequester = remember { BringIntoViewRequester() } + var amountHeight by remember { mutableIntStateOf(0) } + val compact by remember { derivedStateOf { amountHeight > 0 && scroll.value > amountHeight } } + val scrolled by remember { derivedStateOf { scroll.value > 0 } } + LaunchedEffect(state.splitReveal) { if (state.splitReveal > 0) splitRequester.bringIntoView() } + fun keypad(target: KeypadTarget) { + focusManager.clearFocus() + actions.focusKeypad(target) + } + Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding().imePadding()) { + KvittTopAppBar( + title = stringResource(if (state.isEdit) R.string.expense_editor_edit else R.string.expense_editor_new), + navigation = TopBarNavigation.Close, + onNavigation = actions::close, + scrolled = scrolled, + ) { + KvittButton(stringResource(DesignR.string.common_save), actions::save, icon = KvittIcon.Check, enabled = state.canSave) + Spacer(Modifier.width(KvittTheme.spacing.s3)) + } + val category = state.category + if (compact && category != null) { + CompactEditorSummary(category, form.description, form.amount.ore ?: 0L, { keypad(KeypadTarget.Amount) }) + } + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scroll) + .padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s4), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap), + ) { + AmountDisplay( + form.amount.display(language), + focused = state.keypad == KeypadTarget.Amount, + error = errorText(state.errors, ExpenseEditorViewModel.FIELD_AMOUNT), + onClick = { keypad(KeypadTarget.Amount) }, + modifier = Modifier.onSizeChanged { amountHeight = it.height }, + ) + DescriptionField( + form.description, + actions::onDescription, + error = errorText(state.errors, ExpenseEditorViewModel.FIELD_DESCRIPTION), + onDone = actions::save, + modifier = Modifier.focusRequester(descriptionFocus).onFocusChanged { if (it.hasFocus) actions.focusKeypad(null) }, + ) + CategoryQuickRow(state.quickRow, form.categoryId, { actions.onCategory(it.id) }, { actions.showOverlay(EditorOverlay.CategoryGrid) }) + EditorChipRow { + state.account?.let { AccountChip(it.name, state.accountColorIndex, { actions.showOverlay(EditorOverlay.AccountPicker) }) } + DateChip(form.date, { actions.showOverlay(EditorOverlay.DatePicker) }) + val payer = if (form.payer == me.id) me else partner + PayerChip(payer, if (payer == me) partner else me, actions::togglePayer) + SplitChip(modeChipLabel(state), state.splitOpen, actions::toggleSplit) + } + errorText(state.errors, ExpenseEditorViewModel.FIELD_DATE)?.let { ErrorLine(it) } + if (!state.splitOpen) { + SplitPreview(state.preview, me, partner) + } else { + SplitSection( + form.mode, + defaultLabel(state), + actions::onMode, + actions::toggleSplit, + Modifier.bringIntoViewRequester(splitRequester), + ) { SplitBody(state, actions, ::keypad) } + } + NotesField( + form.notes, + actions::onNotes, + state.notesExpanded, + actions::expandNotes, + Modifier.onFocusChanged { if (it.hasFocus) actions.focusKeypad(null) }, + ) + errorText(state.errors, ExpenseEditorViewModel.FIELD_NOTES)?.let { ErrorLine(it) } + if (state.isEdit) { + KvittButton(stringResource(R.string.expense_editor_delete), { actions.showOverlay(EditorOverlay.Delete) }, kind = ButtonKind.DangerText, icon = KvittIcon.Delete) + } + } + val target = state.keypad + if (target != null) { + AmountKeypad( + onKey = { key -> if (!actions.onKey(key)) haptics.performHapticFeedback(HapticFeedbackType.Reject) }, + compact = target != KeypadTarget.Amount, + ) + } + } +} + +@Composable +private fun SplitBody(state: ExpenseEditorState, actions: ExpenseEditorActions, keypad: (KeypadTarget) -> Unit) { + val me = state.me ?: return + val partner = state.partner ?: return + val form = state.form ?: return + val language = kvittLanguage() + when (form.mode) { + SplitMode.DEFAULT -> { + SplitBar(accountViewerPercent(state).toFloat()) + SplitPreview(state.preview, me, partner) + } + SplitMode.RATIO -> RatioEditor( + me, + partner, + viewerPercent = ((form.ratioViewer.ore ?: 0L) / PERCENT_HUNDREDTHS).toInt(), + viewerText = form.ratioViewer.display(language), + partnerText = form.ratioPartner.display(language), + focused = when (state.keypad) { + KeypadTarget.RatioViewer -> RatioField.Viewer + KeypadTarget.RatioPartner -> RatioField.Partner + else -> null + }, + onSlider = actions::onViewerPercent, + onFocus = { keypad(if (it == RatioField.Viewer) KeypadTarget.RatioViewer else KeypadTarget.RatioPartner) }, + onPreset = actions::onViewerPercent, + shares = state.preview, + error = errorText(state.errors, ExpenseEditorViewModel.FIELD_SPLIT), + ) + SplitMode.EXACT -> ExactSplitEditor( + me, + partner, + typed = form.exactTyped, + typedText = form.exactInput.display(language), + derivedOre = state.preview?.firstOrNull { it.userId != form.exactTyped }?.shareOre, + focused = state.keypad == KeypadTarget.Exact, + amountOre = form.amount.ore, + typedOre = form.exactInput.ore, + onTypedChange = actions::onExactTyped, + onFocus = { keypad(KeypadTarget.Exact) }, + ) + SplitMode.FULL -> FullSplitPicker(me, partner, form.fullOn, actions::onFullOn, state.preview) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditorOverlays(state: ExpenseEditorState, actions: ExpenseEditorActions) { + when (state.overlay) { + EditorOverlay.None -> Unit + EditorOverlay.AccountPicker, EditorOverlay.CategoryGrid -> + ModalBottomSheet(actions::dismissOverlay, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)) { + EditorSheetContent(state, actions) + } + EditorOverlay.DatePicker, EditorOverlay.Discard, EditorOverlay.Delete -> EditorDialog(state, actions, inline = false) + } +} + +/** The content of the account or category sheet (design-spec §3.7 `AccountPickerSheet`, `CategoryGridSheet`). */ +@Composable +internal fun EditorSheetContent(state: ExpenseEditorState, actions: ExpenseEditorActions) { + val form = state.form ?: return + val partner = state.partner ?: return + val padding = Modifier.padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, bottom = KvittTheme.spacing.s6) + when (state.overlay) { + EditorOverlay.AccountPicker -> AccountPicker(state.accounts, partner, form.accountId, { actions.onAccount(it.summary.account.id) }, padding) + EditorOverlay.CategoryGrid -> CategoryGrid(state.categories, form.categoryId, { actions.onCategory(it.id) }, padding) + else -> Unit + } +} + +/** + * The date picker, discard and delete dialogs of S-7. [inline] draws the dialog's content without a window (the + * screenshot tests, as `ConfirmDialogContent` in `:core:design`). + */ +@Composable +internal fun EditorDialog(state: ExpenseEditorState, actions: ExpenseEditorActions, inline: Boolean) { + val form = state.form ?: return + when (state.overlay) { + EditorOverlay.DatePicker -> EditorDatePicker(form.date, state.today, actions, inline) + EditorOverlay.Discard -> { + val title = stringResource(if (state.isEdit) DesignR.string.common_discard_title else R.string.expense_editor_discard_title) + val confirm = stringResource(DesignR.string.common_discard) + val dismiss = stringResource(DesignR.string.common_keep_editing) + if (inline) { + ConfirmDialogContent(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true) + } else { + ConfirmDialog(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true) + } + } + EditorOverlay.Delete -> DeleteExpenseDialog(actions::confirmDelete, actions::dismissOverlay, inline) + else -> Unit + } +} + +/** "Ta bort utgiften?" / "Den försvinner för er båda." → "Ta bort" (AC-8.9; S-7 and S-8). */ +@Composable +internal fun DeleteExpenseDialog(onConfirm: () -> Unit, onDismiss: () -> Unit, inline: Boolean = false) { + val title = stringResource(R.string.expense_detail_delete_title) + val body = stringResource(R.string.expense_detail_delete_body) + val confirm = stringResource(DesignR.string.common_delete) + val dismiss = stringResource(DesignR.string.common_cancel) + if (inline) { + ConfirmDialogContent(KvittIcon.Delete, title, body, confirm, dismiss, onConfirm, onDismiss, destructive = true) + } else { + ConfirmDialog(KvittIcon.Delete, title, body, confirm, dismiss, onConfirm, onDismiss, destructive = true) + } +} + +/** Material `DatePickerDialog` limited to the §9.6 range: 2000-01-01 to today + 365 days. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditorDatePicker(date: String, today: String, actions: ExpenseEditorActions, inline: Boolean) { + // The picker's calendar is built for the locale it was first composed in; a new locale gets a new picker. + key(LocalConfiguration.current.locales) { LocalizedDatePicker(date, today, actions, inline) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocalizedDatePicker(date: String, today: String, actions: ExpenseEditorActions, inline: Boolean) { + val first = remember { LocalDate(FIRST_YEAR, 1, 1).toEpochDays() * DAY_MILLIS } + val last = remember(today) { LocalDate.parse(today).plus(MAX_DAYS_AHEAD, DateTimeUnit.DAY).toEpochDays() * DAY_MILLIS } + val picker = rememberDatePickerState( + initialSelectedDateMillis = LocalDate.parse(date).toEpochDays() * DAY_MILLIS, + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis in first..last + }, + ) + val confirm: @Composable () -> Unit = { + TextButton({ picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it / DAY_MILLIS).toString()) } ?: actions.dismissOverlay() }) { + Text(stringResource(android.R.string.ok)) + } + } + val dismiss: @Composable () -> Unit = { TextButton(actions::dismissOverlay) { Text(stringResource(DesignR.string.common_cancel)) } } + if (inline) { + Surface(shape = DatePickerDefaults.shape, color = DatePickerDefaults.colors().containerColor) { + Column { + DatePicker(picker) + Row(Modifier.align(Alignment.End).padding(KvittTheme.spacing.s2)) { + dismiss() + confirm() + } + } + } + } else { + DatePickerDialog(onDismissRequest = actions::dismissOverlay, confirmButton = confirm, dismissButton = dismiss) { DatePicker(picker) } + } +} + +@Composable +private fun ErrorLine(text: String) { + Text(text, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = KvittTheme.spacing.s4)) +} + +/** The viewer's percent of the account's default ratio (bp in `Account.defaultShares`, §14.2.4). */ +private fun accountViewerPercent(state: ExpenseEditorState): Int { + val me = state.me ?: return HALF + val bp = state.account?.defaultShares?.get(me.id) ?: return HALF + return (bp / PERCENT_HUNDREDTHS).toInt() +} + +@Composable +private fun defaultLabel(state: ExpenseEditorState): String = defaultModeLabel(accountViewerPercent(state)) + +/** The Delning chip: "Kontots standard 50/50", "Egen fördelning 70/30", "Exakta belopp", "Allt på Anna" (design-spec §3.7). */ +@Composable +private fun modeChipLabel(state: ExpenseEditorState): String { + val form = state.form ?: return "" + return when (form.mode) { + SplitMode.DEFAULT -> defaultLabel(state) + SplitMode.RATIO -> { + val language = kvittLanguage() + val viewer = form.ratioViewer.ore ?: return splitModeLabel(form.mode, "") + val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language)) + splitModeLabel(form.mode, "") + " " + ratio + } + SplitMode.EXACT -> splitModeLabel(form.mode, "") + SplitMode.FULL -> { + val partner = state.partner + when { + form.fullOn == state.me?.id -> stringResource(R.string.expense_split_full_on_you) + partner == null -> splitModeLabel(form.mode, "") + else -> stringResource(DesignR.string.editor_split_full_on, partner.displayName) + } + } + } +} + +/** The inline text of the first error on [field] (design-spec S-7 "Interactions"), or null. */ +@Composable +internal fun errorText(errors: List, field: String): String? { + val error = errors.firstOrNull { it.field == field } ?: return null + return when (field) { + ExpenseEditorViewModel.FIELD_AMOUNT -> when (error.error) { + ExpenseEditorViewModel.REQUIRED -> stringResource(DesignR.string.error_amount_required) + else -> stringResource(DesignR.string.error_amount_out_of_range, money(MIN_AMOUNT_ORE), money(AmountInput.MAX_AMOUNT_ORE)) + } + ExpenseEditorViewModel.FIELD_DESCRIPTION -> when (error.error) { + ExpenseEditorViewModel.REQUIRED -> stringResource(DesignR.string.error_description_required) + else -> pluralStringResource(DesignR.plurals.error_too_long, MAX_DESCRIPTION, MAX_DESCRIPTION) + } + ExpenseEditorViewModel.FIELD_NOTES -> pluralStringResource(DesignR.plurals.error_too_long, MAX_NOTES, MAX_NOTES) + ExpenseEditorViewModel.FIELD_DATE -> stringResource(DesignR.string.error_date_out_of_range) + ExpenseEditorViewModel.FIELD_SPLIT -> when (error.error) { + "exact_sum_mismatch", "exact_negative" -> stringResource(DesignR.string.error_exact_share_too_large) + "bp_out_of_range", "bp_sum_not_10000" -> stringResource(DesignR.string.error_ratio_invalid) + else -> stringResource(DesignR.string.error_generic_invalid) + } + else -> stringResource(DesignR.string.error_generic_invalid) + } +} + +private const val PERCENT_HUNDREDTHS = 100L +private const val HALF = 50 +private const val MIN_AMOUNT_ORE = 1L +private const val MAX_DESCRIPTION = 100 +private const val MAX_NOTES = 1000 +private const val FIRST_YEAR = 2000 +private const val MAX_DAYS_AHEAD = 365 +private const val DAY_MILLIS = 86_400_000L diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt new file mode 100644 index 0000000..4b71b02 --- /dev/null +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt @@ -0,0 +1,561 @@ +package se.hagstedt.kvitt.feature.expense + +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.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.design.component.AccountChoice +import se.hagstedt.kvitt.design.component.quickRowCategories +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountRepository +import se.hagstedt.kvitt.model.AccountSummary +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.ExpenseDraft +import se.hagstedt.kvitt.model.ExpenseEditorOptions +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.ExpenseRepository +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.SaveResult +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId +import javax.inject.Inject + +/** Which keypad-driven field has the in-app keypad (design-spec §3.7, §5.2); null hides the keypad. */ +enum class KeypadTarget { Amount, RatioViewer, RatioPartner, Exact } + +/** What is shown over the editor: a sheet, the date picker or a confirmation (S-7). */ +enum class EditorOverlay { None, AccountPicker, CategoryGrid, DatePicker, Discard, Delete } + +/** + * The values the user edits (S-7). Percent fields hold hundredths of a percent in an [AmountInput] (so "60" is 6000 + * bp) and money fields öre; all parsing is string-exact (SPEC §9.6). + */ +data class EditorForm( + val id: ExpenseId?, + val accountId: AccountId?, + val categoryId: CategoryId?, + val amount: AmountInput, + val description: String, + val notes: String, + val date: String, + val payer: UserId, + val mode: SplitMode, + val ratioViewer: AmountInput, + val ratioPartner: AmountInput, + val exactTyped: UserId, + val exactInput: AmountInput, + val fullOn: UserId, +) + +/** The editor's whole state as S-7 renders it. [ready] is false until the options (and, editing, the expense) are read. */ +data class ExpenseEditorState( + val ready: Boolean = false, + val missing: Boolean = false, + val isEdit: Boolean = false, + val me: User? = null, + val partner: User? = null, + val form: EditorForm? = null, + val accounts: List = emptyList(), + val account: Account? = null, + val accountColorIndex: Int = 0, + val categories: List = emptyList(), + val quickRow: List = emptyList(), + val category: Category? = null, + val preview: List? = null, + val keypad: KeypadTarget? = null, + val splitOpen: Boolean = false, + val splitReveal: Int = 0, + val notesExpanded: Boolean = false, + val overlay: EditorOverlay = EditorOverlay.None, + val errors: List = emptyList(), + val online: Boolean = true, + val today: String = "", + val saving: Boolean = false, +) { + /** The typed exact share exceeds the amount (AC-8.7): inline error, saving blocked. */ + val exactTooLarge: Boolean + get() { + val f = form ?: return false + val typed = f.exactInput.ore ?: return false + val amount = f.amount.ore ?: return false + return f.mode == SplitMode.EXACT && typed > amount + } + + /** "Spara": amount > 0, a description, a category and a computable preview (design-spec S-7). */ + val canSave: Boolean + get() { + val f = form ?: return false + return !saving && (f.amount.ore ?: 0L) > 0 && f.description.isNotBlank() && f.categoryId != null && f.accountId != null && + preview != null && !exactTooLarge + } +} + +/** One-off results the entry point turns into navigation. */ +sealed interface EditorEvent { + /** Saved: [isNew] goes back to the opener with a snackbar ([offline] picks its wording), an edit back to S-8. */ + data class Saved(val id: ExpenseId, val isNew: Boolean, val offline: Boolean) : EditorEvent + data object Deleted : EditorEvent + data object Closed : EditorEvent + + /** Validation moved focus to the description field. */ + data object FocusDescription : EditorEvent +} + +/** + * S-7 state holder (design-spec §4 S-7 and §5; SPEC AC-8.3 to AC-8.9, §9.11 CS-1 … CS-7). Arguments: [ARG_EXPENSE_ID] + * edits that expense; otherwise a new expense, in [ARG_ACCOUNT_ID] when opened from an account (S-5). + */ +@HiltViewModel +class ExpenseEditorViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val expenses: ExpenseRepository, + home: HomeRepository, + accounts: AccountRepository, + connectivity: ConnectivityRepository, +) : ViewModel(), ExpenseEditorActions { + private val editId: ExpenseId? = savedStateHandle.get(ARG_EXPENSE_ID)?.let(::ExpenseId) + private val openerAccount: AccountId? = savedStateHandle.get(ARG_ACCOUNT_ID)?.let(::AccountId) + + private data class Environment(val options: ExpenseEditorOptions, val summaries: List, val all: List, val online: Boolean) + + private data class Ui( + val form: EditorForm? = null, + val initial: EditorForm? = null, + val missing: Boolean = false, + val frequent: List = emptyList(), + val pickedByHand: Boolean = false, + val keypad: KeypadTarget? = null, + val splitOpen: Boolean = false, + val splitReveal: Int = 0, + val notesExpanded: Boolean = false, + val overlay: EditorOverlay = EditorOverlay.None, + val errors: List = emptyList(), + val saving: Boolean = false, + ) + + private val environment = MutableStateFlow(null) + private val ui = MutableStateFlow(Ui()) + private val events = Channel(Channel.BUFFERED) + private var suggestion: Job? = null + + /** One-off events (navigation, focus). */ + val eventFlow: Flow = events.receiveAsFlow() + + val state: StateFlow = combine(environment, ui) { env, u -> derive(env, u) } + .stateIn(viewModelScope, SharingStarted.Eagerly, ExpenseEditorState()) + + init { + viewModelScope.launch { + combine( + expenses.editorOptions(), + home.home().map { it.active + it.archived }, + accounts.all(), + connectivity.online, + ) { options, summaries, all, online -> Environment(options, summaries, all, online) } + .collect { env -> + val first = environment.value == null + environment.value = env + if (first) open(env.options) + } + } + } + + private suspend fun open(options: ExpenseEditorOptions) { + val me = options.me.id + val partner = options.partner.id + if (editId != null) { + val draft = expenses.load(editId) + if (draft == null) { + ui.update { it.copy(missing = true) } + return + } + val viewerBp = draft.ratioBp?.get(me) + val exactMine = draft.exactOre?.get(me) + val form = EditorForm( + id = draft.id, + accountId = draft.accountId, + categoryId = draft.categoryId, + amount = draft.amountOre?.let(::moneyInput) ?: AmountInput(), + description = draft.description, + notes = draft.notes, + date = draft.date, + payer = draft.payer, + mode = draft.mode, + ratioViewer = viewerBp?.let { AmountInput.of(it.toLong()) } ?: AmountInput(), + ratioPartner = viewerBp?.let { AmountInput.of(BP_TOTAL - it) } ?: AmountInput(), + exactTyped = me, + exactInput = exactMine?.let(::moneyInput) ?: AmountInput(), + fullOn = draft.fullOn ?: if (draft.payer == me) partner else me, + ) + ui.update { + it.copy( + form = form, + initial = form, + frequent = draft.accountId?.let { a -> options.frequentCategoryIds[a] }.orEmpty(), + // CS-1: editing never suggests; the saved category stays. + pickedByHand = true, + splitOpen = draft.mode != SplitMode.DEFAULT, + notesExpanded = draft.notes.isNotEmpty(), + ) + } + } else { + val activeIds = options.accounts.map { it.id } + val accountId = openerAccount?.takeIf { it in activeIds } ?: options.defaultAccountId + val form = EditorForm( + id = null, + accountId = accountId, + categoryId = accountId?.let { options.defaultCategoryFor[it] }, + amount = AmountInput(), + description = "", + notes = "", + date = options.today, + payer = me, + mode = SplitMode.DEFAULT, + ratioViewer = AmountInput(), + ratioPartner = AmountInput(), + exactTyped = me, + exactInput = AmountInput(), + fullOn = partner, + ) + ui.update { + it.copy(form = form, initial = form, frequent = accountId?.let { a -> options.frequentCategoryIds[a] }.orEmpty(), keypad = KeypadTarget.Amount) + } + } + } + + private fun derive(env: Environment?, u: Ui): ExpenseEditorState { + if (env == null) return ExpenseEditorState() + val options = env.options + if (u.missing) return ExpenseEditorState(ready = true, missing = true, isEdit = true, online = env.online, today = options.today) + val form = u.form ?: return ExpenseEditorState(online = env.online, today = options.today) + val summaries = env.summaries.associateBy { it.account.id } + val colorIndex = env.all.withIndex().associate { (i, a) -> a.id to i } + val choices = options.accounts.map { a -> AccountChoice(summaries[a.id] ?: AccountSummary(a, 0, 0), colorIndex[a.id] ?: 0) } + val account = form.accountId?.let { id -> options.accounts.firstOrNull { it.id == id } ?: env.all.firstOrNull { it.id == id } } + val active = options.categories.filter { !it.archived }.sortedBy { it.sortOrder } + // D-059/D-060: the account's used categories, filled up to five from sort_order; the selected one takes the fifth place. + val frequent = u.frequent.ifEmpty { active.take(QUICK_ROW).map { it.id } } + return ExpenseEditorState( + ready = true, + isEdit = form.id != null, + me = options.me, + partner = options.partner, + form = form, + accounts = choices, + account = account, + accountColorIndex = account?.let { colorIndex[it.id] } ?: 0, + categories = active, + quickRow = quickRowCategories(active, frequent, form.categoryId), + category = form.categoryId?.let { id -> options.categories.firstOrNull { it.id == id } }, + preview = expenses.preview(draftOf(form, options.me.id, options.partner.id)), + keypad = u.keypad, + splitOpen = u.splitOpen, + splitReveal = u.splitReveal, + notesExpanded = u.notesExpanded, + overlay = u.overlay, + errors = u.errors, + online = env.online, + today = options.today, + saving = u.saving, + ) + } + + private fun editForm(change: (EditorForm) -> EditorForm) = ui.update { u -> u.form?.let { u.copy(form = change(it)) } ?: u } + + private fun clearError(vararg fields: String) = ui.update { u -> u.copy(errors = u.errors.filter { it.field !in fields }) } + + /** A key of the in-app keypad for the focused field; false when the key is rejected (§9.6, reject haptic). */ + override fun onKey(key: AmountKey): Boolean { + val target = ui.value.keypad ?: return false + val form = ui.value.form ?: return false + return when (target) { + KeypadTarget.Amount -> { + val next = form.amount.press(key) ?: return false + editForm { it.copy(amount = next) } + clearError(FIELD_AMOUNT, FIELD_SPLIT) + true + } + KeypadTarget.Exact -> { + val next = form.exactInput.press(key) ?: return false + editForm { it.copy(exactInput = next) } + clearError(FIELD_SPLIT) + true + } + KeypadTarget.RatioViewer, KeypadTarget.RatioPartner -> { + val typed = if (target == KeypadTarget.RatioViewer) form.ratioViewer else form.ratioPartner + val next = typed.press(key, AmountInput.MAX_PERCENT_HUNDREDTHS) ?: return false + val other = next.ore?.let { AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput() + editForm { + if (target == KeypadTarget.RatioViewer) it.copy(ratioViewer = next, ratioPartner = other) else it.copy(ratioPartner = next, ratioViewer = other) + } + clearError(FIELD_SPLIT) + true + } + } + } + + /** Gives the keypad to [target] (a tap on a keypad field), or hides it (null: a text field took focus). */ + override fun focusKeypad(target: KeypadTarget?) = ui.update { it.copy(keypad = target) } + + override fun onDescription(text: String) { + editForm { it.copy(description = text) } + clearError(FIELD_DESCRIPTION) + suggest() + } + + override fun onNotes(text: String) { + editForm { it.copy(notes = text) } + clearError(FIELD_NOTES) + } + + override fun expandNotes() = ui.update { it.copy(notesExpanded = true, keypad = null) } + + /** CS-2: any tap on a category tile, also the selected one, marks the category as picked by hand. */ + override fun onCategory(id: CategoryId) { + suggestion?.cancel() + ui.update { u -> u.copy(pickedByHand = true, overlay = EditorOverlay.None, form = u.form?.copy(categoryId = id), errors = u.errors.filter { it.field != FIELD_CATEGORY }) } + } + + /** AC-8.8: in mode `default` the new account's ratio applies by itself (the draft carries no ratio); UX-9 re-reads the row. */ + override fun onAccount(id: AccountId) { + val options = environment.value?.options ?: return + ui.update { u -> u.copy(overlay = EditorOverlay.None, frequent = options.frequentCategoryIds[id].orEmpty(), form = u.form?.copy(accountId = id)) } + clearError(FIELD_ACCOUNT) + suggest() + } + + override fun onDate(iso: String) { + ui.update { u -> u.copy(overlay = EditorOverlay.None, form = u.form?.copy(date = iso)) } + clearError(FIELD_DATE) + } + + /** AC-8.3: one tap toggles the payer between the two users. */ + override fun togglePayer() { + val options = environment.value?.options ?: return + editForm { it.copy(payer = if (it.payer == options.me.id) options.partner.id else options.me.id) } + } + + /** Opens or closes the split section (design-spec §5.4); opening scrolls it into view. */ + override fun toggleSplit() = ui.update { u -> + if (u.splitOpen) u.copy(splitOpen = false, keypad = if (u.keypad in SPLIT_TARGETS) null else u.keypad) + else u.copy(splitOpen = true, splitReveal = u.splitReveal + 1) + } + + /** One tap picks a mode (design-spec §5.4): ratio starts at the account's ratio, exact with the viewer's field focused, full on the person who did not pay. */ + override fun onMode(mode: SplitMode) { + val env = environment.value ?: return + val me = env.options.me.id + val partner = env.options.partner.id + val current = ui.value.form ?: return + val account = current.accountId?.let { id -> env.all.firstOrNull { it.id == id } } + val viewerBp = (account?.defaultShares?.get(me) ?: HALF_BP).toLong() + ui.update { u -> + val form = u.form ?: return@update u + val next = when (mode) { + SplitMode.DEFAULT -> form.copy(mode = mode) + SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.of(viewerBp), ratioPartner = AmountInput.of(BP_TOTAL - viewerBp)) + SplitMode.EXACT -> form.copy(mode = mode, exactTyped = me, exactInput = AmountInput()) + SplitMode.FULL -> form.copy(mode = mode, fullOn = if (form.payer == me) partner else me) + } + u.copy( + form = next, + splitReveal = u.splitReveal + 1, + keypad = when (mode) { + SplitMode.EXACT -> KeypadTarget.Exact + else -> if (u.keypad in SPLIT_TARGETS) null else u.keypad + }, + errors = u.errors.filter { it.field != FIELD_SPLIT }, + ) + } + } + + /** Ratio slider and presets: whole percent for the viewer. */ + override fun onViewerPercent(percent: Int) { + val bp = percent.coerceIn(0, PERCENT) * PERCENT.toLong() + editForm { it.copy(ratioViewer = AmountInput.of(bp), ratioPartner = AmountInput.of(BP_TOTAL - bp)) } + clearError(FIELD_SPLIT) + } + + /** AC-8.7: tapping the read-only field makes that person's share the typed one. */ + override fun onExactTyped(user: UserId) { + editForm { it.copy(exactTyped = user, exactInput = AmountInput()) } + ui.update { it.copy(keypad = KeypadTarget.Exact) } + } + + override fun onFullOn(user: UserId) = editForm { it.copy(fullOn = user) } + + override fun showOverlay(overlay: EditorOverlay) = ui.update { it.copy(overlay = overlay, keypad = if (overlay == EditorOverlay.None) it.keypad else null) } + + override fun dismissOverlay() = ui.update { it.copy(overlay = EditorOverlay.None) } + + /** Close or back: with changes asks first ("Slänga utgiften?"), without changes closes. */ + override fun close() { + val u = ui.value + if (u.form != null && u.form != u.initial) ui.update { it.copy(overlay = EditorOverlay.Discard) } else events.trySend(EditorEvent.Closed) + } + + /** Back with the keypad up hides it instead of closing (design-spec §3.7). */ + override fun back() { + when { + ui.value.overlay != EditorOverlay.None -> dismissOverlay() + ui.value.keypad != null -> focusKeypad(null) + else -> close() + } + } + + override fun discard() { + ui.update { it.copy(overlay = EditorOverlay.None) } + events.trySend(EditorEvent.Closed) + } + + override fun confirmDelete() { + val id = ui.value.form?.id ?: return + ui.update { it.copy(overlay = EditorOverlay.None) } + viewModelScope.launch { + expenses.delete(id) + events.send(EditorEvent.Deleted) + } + } + + /** "Spara" or the description's Done. Invalid fields come back inline; focus moves to the first one. */ + override fun save() { + val current = state.value + val form = current.form ?: return + val me = current.me ?: return + val partner = current.partner ?: return + if (!current.canSave) { + focusFirstInvalid(localErrors(current)) + return + } + ui.update { it.copy(saving = true) } + viewModelScope.launch { + when (val result = expenses.save(draftOf(form, me.id, partner.id))) { + is SaveResult.Saved -> { + ui.update { it.copy(saving = false, initial = it.form) } + events.send(EditorEvent.Saved(result.id, isNew = form.id == null, offline = !current.online)) + } + is SaveResult.Invalid -> { + ui.update { it.copy(saving = false) } + focusFirstInvalid(result.errors) + } + } + } + } + + private fun localErrors(s: ExpenseEditorState): List { + val f = s.form ?: return emptyList() + return buildList { + if ((f.amount.ore ?: 0L) <= 0L) add(FieldError(FIELD_AMOUNT, REQUIRED)) + if (f.description.isBlank()) add(FieldError(FIELD_DESCRIPTION, REQUIRED)) + if (f.categoryId == null) add(FieldError(FIELD_CATEGORY, REQUIRED)) + if (s.exactTooLarge) add(FieldError(FIELD_SPLIT, EXACT_SUM_MISMATCH)) + } + } + + private fun focusFirstInvalid(errors: List) { + ui.update { it.copy(errors = errors) } + val fields = errors.map { it.field }.toSet() + when { + FIELD_AMOUNT in fields -> focusKeypad(KeypadTarget.Amount) + FIELD_DESCRIPTION in fields -> { + focusKeypad(null) + events.trySend(EditorEvent.FocusDescription) + } + FIELD_SPLIT in fields -> ui.update { it.copy(splitOpen = true, splitReveal = it.splitReveal + 1) } + } + } + + /** CS-3: while the category was not picked by hand, re-run the suggestion (150 ms debounce) on description and account changes. */ + private fun suggest() { + if (ui.value.pickedByHand || editId != null) return + suggestion?.cancel() + suggestion = viewModelScope.launch { + delay(SUGGESTION_DEBOUNCE_MS) + val form = ui.value.form ?: return@launch + val options = environment.value?.options ?: expenses.editorOptions().first() + val suggested = expenses.suggestCategory(form.accountId, form.description) + // A null result leaves the step-4 default (CS-5 step 4; CS-7 for an empty description). + val category = suggested ?: form.accountId?.let { options.defaultCategoryFor[it] } + if (!ui.value.pickedByHand) ui.update { u -> u.copy(form = u.form?.copy(categoryId = category)) } + } + } + + companion object { + /** Navigation argument: the id of the expense to edit (absent for a new expense). */ + const val ARG_EXPENSE_ID = "expenseId" + + /** Navigation argument: the account a new expense starts in when opened from S-5. */ + const val ARG_ACCOUNT_ID = "accountId" + + const val FIELD_AMOUNT = "amountOre" + const val FIELD_DESCRIPTION = "description" + const val FIELD_NOTES = "notes" + const val FIELD_DATE = "date" + const val FIELD_SPLIT = "split" + const val FIELD_ACCOUNT = "accountId" + const val FIELD_CATEGORY = "categoryId" + const val REQUIRED = "required" + const val EXACT_SUM_MISMATCH = "exact_sum_mismatch" + + /** design-spec §5.3. */ + const val SUGGESTION_DEBOUNCE_MS = 150L + + private const val BP_TOTAL = 10_000L + private const val HALF_BP = 5000 + private const val PERCENT = 100 + private const val QUICK_ROW = 5 + private val SPLIT_TARGETS = setOf(KeypadTarget.RatioViewer, KeypadTarget.RatioPartner, KeypadTarget.Exact) + + /** A stored amount as editable text with both decimals when it has öre: 124 760 -> "1247.60", 41 200 -> "412". */ + fun moneyInput(ore: Long): AmountInput { + val cents = ore % ORE_PER_KRONA + return AmountInput(if (cents == 0L) (ore / ORE_PER_KRONA).toString() else "${ore / ORE_PER_KRONA}." + cents.toString().padStart(2, '0')) + } + + private const val ORE_PER_KRONA = 100L + + /** The draft of §14.2.4 from the form: exact derives the other share as amount minus the typed one (AC-8.7). */ + fun draftOf(form: EditorForm, me: UserId, partner: UserId): ExpenseDraft { + val amount = form.amount.ore + val viewerBp = form.ratioViewer.ore + val typed = form.exactInput.ore + val other = if (form.exactTyped == me) partner else me + return ExpenseDraft( + id = form.id, + accountId = form.accountId, + categoryId = form.categoryId, + amountOre = amount, + description = form.description, + notes = form.notes, + date = form.date, + payer = form.payer, + mode = form.mode, + ratioBp = if (form.mode == SplitMode.RATIO && viewerBp != null) mapOf(me to viewerBp.toInt(), partner to (BP_TOTAL - viewerBp).toInt()) else null, + exactOre = if (form.mode == SplitMode.EXACT && amount != null && typed != null && typed <= amount) mapOf(form.exactTyped to typed, other to amount - typed) else null, + fullOn = if (form.mode == SplitMode.FULL) form.fullOn else null, + ) + } + } +} diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEntry.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEntry.kt deleted file mode 100644 index cbbc881..0000000 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEntry.kt +++ /dev/null @@ -1,14 +0,0 @@ -package se.hagstedt.kvitt.feature.expense - -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen - * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). - */ -@Composable -fun ExpenseEntry(modifier: Modifier = Modifier) { - Box(modifier) -} diff --git a/android/feature/expense/src/main/res/values-sv/strings.xml b/android/feature/expense/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..3447672 --- /dev/null +++ b/android/feature/expense/src/main/res/values-sv/strings.xml @@ -0,0 +1,25 @@ + + + + Ny utgift + Ändra utgift + Datum + Ta bort utgift + Slänga utgiften? + Utgift + Konto + Datum + Betalt av + Delning + Anteckning + Återkommande: %1$s + Ta bort utgiften? + Den försvinner för er båda. + %1$s tog bort den här utgiften · %2$s + Den räknas inte längre i saldon eller statistik. + Utgiften finns inte på telefonen än + Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den. + + Synka nu + Allt på dig + diff --git a/android/feature/expense/src/main/res/values/strings.xml b/android/feature/expense/src/main/res/values/strings.xml new file mode 100644 index 0000000..39fa2bb --- /dev/null +++ b/android/feature/expense/src/main/res/values/strings.xml @@ -0,0 +1,25 @@ + + + + New expense + Edit expense + Date + Delete expense + Discard the expense? + Expense + Account + Date + Paid by + Split + Notes + Recurring: %1$s + Delete the expense? + It disappears for both of you. + %1$s deleted this expense · %2$s + It no longer counts in balances or analytics. + This expense is not on the phone yet + It may have just been added on another phone. Sync to fetch it. + + Sync now + All on you + diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt new file mode 100644 index 0000000..c846ac8 --- /dev/null +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailViewModelTest.kt @@ -0,0 +1,87 @@ +package se.hagstedt.kvitt.feature.expense + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import se.hagstedt.kvitt.fakes.FakeRepositories +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios +import se.hagstedt.kvitt.model.ExpenseId + +/** S-8 state holder: loaded, pending timeline line, deleted, missing, delete flow. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ExpenseDetailViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun TestScope.detail(id: ExpenseId, scenario: FakeScenario = ExpenseScenarios.household): Pair { + val fakes = FakeRepositories(scenario) + val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync) + advanceUntilIdle() + return vm to fakes + } + + @Test + fun loaded_withThePendingLineLast() = runTest(dispatcher) { + val (vm, _) = detail(ExpenseScenarios.icaId) + val d = vm.state.value.detail + assertFalse(vm.state.value.loading) + assertEquals("ICA Maxi Lindhagen", d?.description) + assertTrue(d?.pending == true) + assertTrue(d?.timeline?.last()?.pendingLocal == true) + assertEquals(4, d?.timeline?.size) + } + + @Test + fun recurring_namesTheTemplate() = runTest(dispatcher) { + val (vm, _) = detail(ExpenseScenarios.hyraId) + assertEquals("Hyra", vm.state.value.detail?.recurringTemplateName) + } + + @Test + fun missing_isNullAfterLoading() = runTest(dispatcher) { + val (vm, _) = detail(ExpenseScenarios.missingId) + assertFalse(vm.state.value.loading) + assertNull(vm.state.value.detail) + } + + @Test + fun deleted_staysVisibleAsDeleted() = runTest(dispatcher) { + val (vm, _) = detail(ExpenseScenarios.icaId, ExpenseScenarios.deleted) + assertTrue(vm.state.value.detail?.deleted == true) + } + + @Test + fun delete_asksThenDeletes() = runTest(dispatcher) { + val (vm, fakes) = detail(ExpenseScenarios.trananId) + vm.askDelete() + assertTrue(vm.state.value.confirmDelete) + vm.dismissDelete() + assertFalse(vm.state.value.confirmDelete) + vm.askDelete() + vm.confirmDelete() + advanceUntilIdle() + assertEquals(DetailEvent.Deleted, vm.eventFlow.first()) + assertTrue(fakes.store.current.expenses.single { it.id == ExpenseScenarios.trananId }.deleted) + assertTrue(vm.state.value.detail?.deleted == true) + } +} diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt new file mode 100644 index 0000000..5753191 --- /dev/null +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt @@ -0,0 +1,295 @@ +package se.hagstedt.kvitt.feature.expense + +import androidx.lifecycle.SavedStateHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.fakes.FakeRepositories +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.FakeScenarios +import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode + +/** S-7 state holder: defaults, keypad input, split modes, the category suggestion, enablement and error mapping. */ +@OptIn(ExperimentalCoroutinesApi::class) +class ExpenseEditorViewModelTest { + private val dispatcher = UnconfinedTestDispatcher() + private val jacob = FakeScenarios.owner.id + private val anna = FakeScenarios.partner.id + + @Before + fun setUp() = Dispatchers.setMain(dispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun TestScope.editor(scenario: FakeScenario = ExpenseScenarios.household, args: Map = emptyMap()): Pair { + val fakes = FakeRepositories(scenario) + val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity) + advanceUntilIdle() + return vm to fakes + } + + private fun ExpenseEditorViewModel.type(vararg keys: String) = keys.forEach { key -> + onKey(if (key == ".") AmountKey.Decimal else AmountKey.Digit(key.toInt())) + } + + @Test + fun newExpense_appliesTheDefaults() = runTest(dispatcher) { + val (vm, _) = editor() + val s = vm.state.value + assertTrue(s.ready) + assertFalse(s.isEdit) + assertEquals(ExpenseScenarios.vardag.id, s.form?.accountId) + assertEquals(ExpenseScenarios.groceries.id, s.form?.categoryId) + assertEquals(jacob, s.form?.payer) + assertEquals(SplitMode.DEFAULT, s.form?.mode) + assertEquals("2026-10-17", s.form?.date) + assertEquals(KeypadTarget.Amount, s.keypad) + assertNull(s.preview) + assertFalse(s.canSave) + } + + @Test + fun openedFromAnAccount_startsInThatAccount() = runTest(dispatcher) { + val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_ACCOUNT_ID to ExpenseScenarios.huset.id.value)) + assertEquals(ExpenseScenarios.huset.id, vm.state.value.form?.accountId) + assertEquals(ExpenseScenarios.housing.id, vm.state.value.form?.categoryId) + } + + @Test + fun tenSecondPath_amountDescriptionSuggestionSave() = runTest(dispatcher) { + val (vm, fakes) = editor() + vm.type("4", "1", "2") + assertEquals(41_200L, vm.state.value.form?.amount?.ore) + assertEquals(listOf(Share(jacob, 20_600, 5000), Share(anna, 20_600, 5000)), vm.state.value.preview) + assertFalse(vm.state.value.canSave) + vm.onDescription("Systembolaget") + advanceUntilIdle() + assertEquals(ExpenseScenarios.entertainment.id, vm.state.value.form?.categoryId) + assertTrue(vm.state.value.canSave) + vm.save() + advanceUntilIdle() + val event = vm.eventFlow.first() + assertTrue(event is EditorEvent.Saved && event.isNew && !event.offline) + assertEquals(1, fakes.store.current.expenses.count { it.description == "Systembolaget" && it.pending }) + } + + @Test + fun suggestion_stopsOncePickedByHand_andClearingReturnsTheDefault() = runTest(dispatcher) { + val (vm, _) = editor() + vm.onDescription("Systembolaget") + advanceUntilIdle() + vm.onDescription("") + advanceUntilIdle() + assertEquals(ExpenseScenarios.groceries.id, vm.state.value.form?.categoryId) + vm.onCategory(ExpenseScenarios.shopping.id) + vm.onDescription("Systembolaget") + advanceUntilIdle() + assertEquals(ExpenseScenarios.shopping.id, vm.state.value.form?.categoryId) + } + + @Test + fun keypad_rejectsAThirdDecimalAndAmountsAboveTheLimit() = runTest(dispatcher) { + val (vm, _) = editor() + vm.type("1", "2", ".", "5", "0") + assertFalse(vm.onKey(AmountKey.Digit(1))) + assertFalse(vm.onKey(AmountKey.Decimal)) + assertEquals(1_250L, vm.state.value.form?.amount?.ore) + vm.onKey(AmountKey.Clear) + vm.type("1", "0", "0", "0", "0", "0", "0", "0") + assertFalse(vm.onKey(AmountKey.Digit(1))) + assertEquals(1_000_000_000L, vm.state.value.form?.amount?.ore) + } + + @Test + fun exactShareLargerThanTheAmount_blocksSaving() = runTest(dispatcher) { + val (vm, _) = editor() + vm.type("1", "0", "6", "0") + vm.onDescription("Ullared") + vm.toggleSplit() + vm.onMode(SplitMode.EXACT) + assertEquals(KeypadTarget.Exact, vm.state.value.keypad) + vm.type("1", "2", "0", "0") + assertTrue(vm.state.value.exactTooLarge) + assertNull(vm.state.value.preview) + assertFalse(vm.state.value.canSave) + vm.onKey(AmountKey.Delete) + vm.onKey(AmountKey.Clear) + vm.type("4", "0", "0") + advanceUntilIdle() + assertEquals(listOf(Share(jacob, 40_000, null), Share(anna, 66_000, null)), vm.state.value.preview) + assertTrue(vm.state.value.canSave) + } + + @Test + fun ratio_typingOneSideSetsTheOther_andStartsAtTheAccountRatio() = runTest(dispatcher) { + val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_ACCOUNT_ID to ExpenseScenarios.huset.id.value)) + vm.type("1", "0", "0") + vm.onMode(SplitMode.RATIO) + assertEquals(6_000L, vm.state.value.form?.ratioViewer?.ore) + vm.focusKeypad(KeypadTarget.RatioPartner) + vm.onKey(AmountKey.Clear) + vm.type("2", "5") + assertEquals(7_500L, vm.state.value.form?.ratioViewer?.ore) + assertEquals(listOf(Share(jacob, 7_500, 7500), Share(anna, 2_500, 2500)), vm.state.value.preview) + vm.type("1") + assertFalse(vm.onKey(AmountKey.Digit(5))) + } + + @Test + fun full_defaultsToThePersonWhoDidNotPay() = runTest(dispatcher) { + val (vm, _) = editor() + vm.onMode(SplitMode.FULL) + assertEquals(anna, vm.state.value.form?.fullOn) + vm.togglePayer() + vm.onMode(SplitMode.FULL) + assertEquals(jacob, vm.state.value.form?.fullOn) + } + + @Test + fun accountChange_inDefaultMode_recopiesTheRatio() = runTest(dispatcher) { + val (vm, _) = editor() + vm.type("1", "0", "0") + vm.onAccount(ExpenseScenarios.huset.id) + advanceUntilIdle() + assertEquals(listOf(Share(jacob, 6_000, 6000), Share(anna, 4_000, 4000)), vm.state.value.preview) + // CS-3: the suggestion re-runs on an account change (empty description: the AC-8.6 default of Huset). + assertEquals(ExpenseScenarios.housing.id, vm.state.value.form?.categoryId) + } + + @Test + fun quickRow_usesTheAccountsFrequentCategories_selectedTakesTheFifthPlace() = runTest(dispatcher) { + val (vm, _) = editor() + val frequent = listOf(ExpenseScenarios.groceries, ExpenseScenarios.transport, ExpenseScenarios.entertainment, ExpenseScenarios.shopping) + assertEquals(frequent.map { it.id }, vm.state.value.quickRow.take(4).map { it.id }) + assertEquals(5, vm.state.value.quickRow.size) + vm.onCategory(ExpenseScenarios.other.id) + assertEquals(frequent.map { it.id } + ExpenseScenarios.other.id, vm.state.value.quickRow.map { it.id }) + } + + @Test + fun offlineSave_isAllowed_andReportsOffline() = runTest(dispatcher) { + val (vm, _) = editor(ExpenseScenarios.offline) + vm.type("5", "0") + vm.onDescription("Pressbyrån") + advanceUntilIdle() + vm.save() + advanceUntilIdle() + val event = vm.eventFlow.first() + assertTrue(event is EditorEvent.Saved && event.offline) + } + + @Test + fun noDefaultCategory_showsNoCategory_andBlocksSaving() = runTest(dispatcher) { + val (vm, _) = editor(ExpenseScenarios.noDefaultCategory) + vm.type("5", "0") + vm.onDescription("Okänt") + advanceUntilIdle() + assertNull(vm.state.value.form?.categoryId) + assertFalse(vm.state.value.canSave) + vm.save() + assertEquals(FieldError(ExpenseEditorViewModel.FIELD_CATEGORY, "required"), vm.state.value.errors.single()) + } + + @Test + fun missingAmountAndDescription_mapToRequired_andFocusTheAmount() = runTest(dispatcher) { + val (vm, _) = editor() + vm.focusKeypad(null) + vm.save() + val fields = vm.state.value.errors.associate { it.field to it.error } + assertEquals("required", fields[ExpenseEditorViewModel.FIELD_AMOUNT]) + assertEquals("required", fields[ExpenseEditorViewModel.FIELD_DESCRIPTION]) + assertEquals(KeypadTarget.Amount, vm.state.value.keypad) + vm.type("1") + assertNull(vm.state.value.errors.firstOrNull { it.field == ExpenseEditorViewModel.FIELD_AMOUNT }) + } + + @Test + fun dateTooFarAhead_comesBackAsValidationFailed() = runTest(dispatcher) { + val (vm, _) = editor() + vm.type("1", "0") + vm.onDescription("Biljetter") + advanceUntilIdle() + vm.onDate("2028-01-01") + vm.save() + advanceUntilIdle() + assertEquals(FieldError(ExpenseEditorViewModel.FIELD_DATE, "validation_failed"), vm.state.value.errors.single()) + } + + @Test + fun editMode_loadsTheExpense_keepsTheCategory_andDeletes() = runTest(dispatcher) { + val (vm, fakes) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.slKortId.value)) + val s = vm.state.value + assertTrue(s.isEdit) + assertEquals(SplitMode.FULL, s.form?.mode) + assertEquals(anna, s.form?.fullOn) + assertTrue(s.splitOpen) + assertTrue(s.notesExpanded) + assertNull(s.keypad) + vm.onDescription("Systembolaget") + advanceUntilIdle() + assertEquals(ExpenseScenarios.transport.id, vm.state.value.form?.categoryId) + vm.showOverlay(EditorOverlay.Delete) + vm.confirmDelete() + advanceUntilIdle() + assertEquals(EditorEvent.Deleted, vm.eventFlow.first()) + assertTrue(fakes.store.current.expenses.single { it.id == ExpenseScenarios.slKortId }.deleted) + } + + @Test + fun editSave_reportsAnEdit() = runTest(dispatcher) { + val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.ullaredId.value)) + assertEquals(40_000L, vm.state.value.form?.exactInput?.ore) + assertEquals(listOf(Share(jacob, 40_000, null), Share(anna, 66_000, null)), vm.state.value.preview) + vm.togglePayer() + vm.save() + advanceUntilIdle() + val event = vm.eventFlow.first() + assertTrue(event is EditorEvent.Saved && !event.isNew) + } + + @Test + fun editedAmounts_keepBothDecimals() = runTest(dispatcher) { + val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value)) + assertEquals("1247.60", vm.state.value.form?.amount?.raw) + assertEquals("412", ExpenseEditorViewModel.moneyInput(41_200).raw) + assertEquals("0.05", ExpenseEditorViewModel.moneyInput(5).raw) + } + + @Test + fun close_asksFirstOnlyWithChanges_andBackHidesTheKeypadFirst() = runTest(dispatcher) { + val (vm, _) = editor() + vm.back() + assertNull(vm.state.value.keypad) + vm.close() + assertEquals(EditorEvent.Closed, vm.eventFlow.first()) + vm.onDescription("Taxi") + vm.close() + assertEquals(EditorOverlay.Discard, vm.state.value.overlay) + vm.discard() + assertEquals(EditorEvent.Closed, vm.eventFlow.first()) + } + + @Test + fun editingADeletedExpense_isMissing() = runTest(dispatcher) { + val (vm, _) = editor(ExpenseScenarios.deleted, mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value)) + assertTrue(vm.state.value.missing) + } +} diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt new file mode 100644 index 0000000..fcd9e00 --- /dev/null +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/Cases.kt @@ -0,0 +1,170 @@ +package se.hagstedt.kvitt.feature.expense.screenshot + +import androidx.compose.runtime.Composable +import androidx.lifecycle.SavedStateHandle +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.fakes.FakeRepositories +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.FakeScenarios +import se.hagstedt.kvitt.fakes.expense.ExpenseScenarios +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.feature.expense.DeleteExpenseDialog +import se.hagstedt.kvitt.feature.expense.EditorDialog +import se.hagstedt.kvitt.feature.expense.EditorOverlay +import se.hagstedt.kvitt.feature.expense.EditorSheetContent +import se.hagstedt.kvitt.feature.expense.ExpenseDetailActions +import se.hagstedt.kvitt.feature.expense.ExpenseDetailContent +import se.hagstedt.kvitt.feature.expense.ExpenseDetailViewModel +import se.hagstedt.kvitt.feature.expense.ExpenseEditorActions +import se.hagstedt.kvitt.feature.expense.ExpenseEditorContent +import se.hagstedt.kvitt.feature.expense.ExpenseEditorViewModel +import se.hagstedt.kvitt.feature.expense.KeypadTarget +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.UserId + +/** A screenshot case: its golden name, whether it has a 200 % golden, and its content. */ +class Case(val name: String, val largeFont: Boolean = false, val content: () -> @Composable () -> Unit) + +/** Actions that do nothing: goldens render a fixed state. */ +object NoActions : ExpenseEditorActions, ExpenseDetailActions { + override fun onKey(key: AmountKey) = true + override fun focusKeypad(target: KeypadTarget?) = Unit + override fun onDescription(text: String) = Unit + override fun onNotes(text: String) = Unit + override fun expandNotes() = Unit + override fun onCategory(id: CategoryId) = Unit + override fun onAccount(id: AccountId) = Unit + override fun onDate(iso: String) = Unit + override fun togglePayer() = Unit + override fun toggleSplit() = Unit + override fun onMode(mode: SplitMode) = Unit + override fun onViewerPercent(percent: Int) = Unit + override fun onExactTyped(user: UserId) = Unit + override fun onFullOn(user: UserId) = Unit + override fun showOverlay(overlay: EditorOverlay) = Unit + override fun dismissOverlay() = Unit + override fun close() = Unit + override fun back() = Unit + override fun discard() = Unit + override fun confirmDelete() = Unit + override fun save() = Unit + override fun askDelete() = Unit + override fun dismissDelete() = Unit + override fun syncNow() = Unit +} + +private fun ExpenseEditorViewModel.type(text: String) = text.forEach { c -> + onKey(if (c == '.') AmountKey.Decimal else AmountKey.Digit(c.digitToInt())) +} + +/** S-7 in [scenario] after [steps] (driven through the real view model and the fakes). */ +private fun editor( + name: String, + scenario: FakeScenario = ExpenseScenarios.household, + edit: ExpenseId? = null, + largeFont: Boolean = false, + steps: ExpenseEditorViewModel.(idle: () -> Unit) -> Unit = {}, +) = Case(name, largeFont) { + val state = ScreenSnapshots.build { idle -> + val fakes = FakeRepositories(scenario) + val args = if (edit == null) emptyMap() else mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to edit.value) + val vm = ExpenseEditorViewModel(SavedStateHandle(args), fakes.expenses, fakes.home, fakes.accounts, fakes.connectivity) + idle() + vm.steps(idle) + idle() + vm.state.value + } + when (state.overlay) { + EditorOverlay.None -> ({ ExpenseEditorContent(state, NoActions) }) + EditorOverlay.AccountPicker, EditorOverlay.CategoryGrid -> ({ + OverlayFrame(sheet = true, { ExpenseEditorContent(state.copy(overlay = EditorOverlay.None), NoActions) }) { + ProvideHousehold(FakeScenarios.owner, FakeScenarios.partner) { EditorSheetContent(state, NoActions) } + } + }) + else -> ({ + OverlayFrame(sheet = false, { ExpenseEditorContent(state.copy(overlay = EditorOverlay.None), NoActions) }) { EditorDialog(state, NoActions, inline = true) } + }) + } +} + +/** S-8 of [id] in [scenario]. */ +private fun detail( + name: String, + id: ExpenseId, + scenario: FakeScenario = ExpenseScenarios.household, + largeFont: Boolean = false, + steps: ExpenseDetailViewModel.() -> Unit = {}, +) = Case(name, largeFont) { + val state = ScreenSnapshots.build { idle -> + val fakes = FakeRepositories(scenario) + val vm = ExpenseDetailViewModel(SavedStateHandle(mapOf(ExpenseDetailViewModel.ARG_EXPENSE_ID to id.value)), fakes.expenses, fakes.home, fakes.connectivity, fakes.sync) + idle() + vm.steps() + vm.state.value + } + if (!state.confirmDelete) { + ({ ExpenseDetailContent(state, NoActions, {}, {}) }) + } else { + ({ OverlayFrame(sheet = false, { ExpenseDetailContent(state.copy(confirmDelete = false), NoActions, {}, {}) }) { DeleteExpenseDialog({}, {}, inline = true) } }) + } +} + +private val tenSecond: ExpenseEditorViewModel.(() -> Unit) -> Unit = { idle -> + type("412") + onDescription("Systembolaget") + idle() +} + +/** Every S-7 state of design-spec §4 S-7 and PLAN-M7c §5 (phone height). */ +val editorCases = listOf( + editor("s7_new_defaults"), + editor("s7_new", largeFont = true, steps = tenSecond), + editor("s7_edit", edit = ExpenseScenarios.icaId), + editor("s7_split_default") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit() }, + editor("s7_split_ratio") { idle -> + onAccount(ExpenseScenarios.huset.id); type("1560"); onDescription("Middag på Tranan"); idle() + onCategory(ExpenseScenarios.restaurants.id); toggleSplit(); onMode(SplitMode.RATIO); onViewerPercent(70); focusKeypad(KeypadTarget.RatioViewer) + }, + editor("s7_split_exact_error") { idle -> + type("1060"); onDescription("Ullared"); idle(); onCategory(ExpenseScenarios.shopping.id); toggleSplit(); onMode(SplitMode.EXACT); type("1200") + }, + editor("s7_split_full_other") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit(); onMode(SplitMode.FULL) }, + editor("s7_split_full_payer") { idle -> tenSecond(idle); focusKeypad(null); toggleSplit(); onMode(SplitMode.FULL); onFullOn(FakeScenarios.owner.id) }, + editor("s7_full_notes_edit", edit = ExpenseScenarios.slKortId), + editor("s7_errors_missing") { _ -> focusKeypad(null); save(); focusKeypad(null) }, + editor("s7_notes_expanded") { idle -> tenSecond(idle); focusKeypad(null); expandNotes(); onNotes("Till middagen på lördag") }, + editor("s7_account_picker") { idle -> tenSecond(idle); showOverlay(EditorOverlay.AccountPicker) }, + editor("s7_category_grid") { idle -> tenSecond(idle); showOverlay(EditorOverlay.CategoryGrid) }, + // September: the picker's today marker (the real clock, not the fixed one) stays out of the golden. + editor("s7_date_picker", edit = ExpenseScenarios.hyraId) { _ -> showOverlay(EditorOverlay.DatePicker) }, + editor("s7_delete_confirm", edit = ExpenseScenarios.icaId) { _ -> showOverlay(EditorOverlay.Delete) }, + editor("s7_discard_confirm") { idle -> tenSecond(idle); close() }, + editor("s7_offline", scenario = ExpenseScenarios.offline, steps = tenSecond), + editor("s7_no_category", scenario = ExpenseScenarios.noDefaultCategory) { idle -> type("89"); onDescription("Loppis"); idle() }, + editor("s7_long", scenario = ExpenseScenarios.long, largeFont = true) { idle -> + type("1234567.89"); onDescription("Snickare Bergström, nytt golv i hallen, köket och vardagsrummet"); idle() + }, + editor("s7_long_edit", scenario = ExpenseScenarios.long, edit = ExpenseScenarios.snickareId), +) + +/** Every S-8 state of design-spec §4 S-8 and PLAN-M7c §5 (phone height). */ +val detailCases = listOf( + detail("s8_loaded_pending", ExpenseScenarios.icaId, largeFont = true), + detail("s8_ratio_timeline", ExpenseScenarios.trananId), + detail("s8_exact", ExpenseScenarios.ullaredId), + detail("s8_full", ExpenseScenarios.slKortId), + detail("s8_recurring", ExpenseScenarios.hyraId), + detail("s8_deleted", ExpenseScenarios.icaId, ExpenseScenarios.deleted), + detail("s8_missing", ExpenseScenarios.missingId), + detail("s8_missing_offline", ExpenseScenarios.missingId, ExpenseScenarios.offline), + detail("s8_offline", ExpenseScenarios.icaId, ExpenseScenarios.offline), + detail("s8_delete_confirm", ExpenseScenarios.trananId) { askDelete() }, +) + +/** Long content on a tall frame so the whole screen shows. */ +val tallCases = listOf( + detail("s8_long", ExpenseScenarios.snickareId, ExpenseScenarios.long), +) diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ExpenseScreenshotTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ExpenseScreenshotTest.kt new file mode 100644 index 0000000..150961e --- /dev/null +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ExpenseScreenshotTest.kt @@ -0,0 +1,55 @@ +package se.hagstedt.kvitt.feature.expense.screenshot + +import androidx.compose.ui.test.junit4.v2.createComposeRule +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 + +/** + * S-7 and S-8 goldens: every state × light/dark × Swedish/English, plus 200 % for the states the design spec draws at + * 200 % (PLAN-M7c §5, §7). Frame: Galaxy S26, 411 × 891 dp. Re-record with + * `tools/gradle-serial.sh :feature:expense:recordRoborazziDebug` and open every changed PNG. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi") +class ExpenseScreenshotTest(private val name: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() { + val case = (editorCases + detailCases).first { it.name == name } + ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content()) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun cases(): List> = (editorCases + detailCases).map { arrayOf(it.name) } + } +} + +/** Long content on a tall frame (411 × 1600 dp) so the whole screen, including a long timeline, is in the golden. */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h1600dp-xxhdpi") +class ExpenseTallScreenshotTest(private val name: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() { + val case = tallCases.first { it.name == name } + ScreenSnapshots.snap(compose, case.name, case.largeFont, case.content()) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun cases(): List> = tallCases.map { arrayOf(it.name) } + } +} diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/OverlayFrame.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/OverlayFrame.kt new file mode 100644 index 0000000..447184f --- /dev/null +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/OverlayFrame.kt @@ -0,0 +1,44 @@ +package se.hagstedt.kvitt.feature.expense.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 androidx.compose.ui.unit.dp + +/** + * A sheet or dialog drawn over the screen in the same window, the way `:core:design` screenshots `ConfirmDialogContent` + * and the sheet contents: Robolectric captures of separate windows lose the configuration's locale (the window's own + * `LocalConfiguration`), so the goldens draw the real content in the Material sheet or dialog frame instead. + */ +@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 = DIALOG_SIDE)) { overlay() } + } + } +} + +private const val SHEET_SCRIM = 0.32f +private const val DIALOG_SCRIM = 0.38f +private val DIALOG_SIDE = 24.dp diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ScreenSnapshots.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ScreenSnapshots.kt new file mode 100644 index 0000000..b42451f --- /dev/null +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/screenshot/ScreenSnapshots.kt @@ -0,0 +1,103 @@ +package se.hagstedt.kvitt.feature.expense.screenshot + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Density +import com.github.takahirom.roborazzi.captureRoboImage +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 kotlinx.datetime.TimeZone +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.KvittClock +import se.hagstedt.kvitt.design.component.LocalKvittClock +import java.util.Locale +import kotlin.time.Instant + +/** + * Captures one golden per language and theme of a whole screen: `src/test/screenshots/__.png`, + * plus `_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish and light, as the spec's 200 % + * frames). The pattern of `:core:design` (M6): content set once, language, theme and font scale switched through a + * configuration context between captures. + * The clock is fixed at 17 October 2026 14:02 in Stockholm, the fake scenarios' "now". + */ +object ScreenSnapshots { + val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm")) + + private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) { + val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else "" + } + + fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean = false, content: @Composable () -> Unit) { + val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } + + if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList() + var current by mutableStateOf(variants.first()) + compose.setContent { + val variant = current + val base = LocalContext.current + val baseConfiguration = LocalConfiguration.current + val context = remember(variant) { + val config = Configuration(baseConfiguration).apply { + setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB"))) + fontScale = variant.fontScale + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + base.createConfigurationContext(config) + } + CompositionLocalProvider( + LocalContext provides context, + LocalConfiguration provides context.resources.configuration, + LocalResources provides context.resources, + LocalDensity provides Density(context), + LocalKvittClock provides clock, + ) { + KvittTheme(darkTheme = variant.dark) { content() } + } + } + // Material's date picker formats months and weekdays with the JVM default locale (the app's locale on a phone). + val hostLocale = Locale.getDefault() + try { + variants.forEach { variant -> + Locale.setDefault(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")) + current = variant + compose.waitForIdle() + compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") + } + } finally { + Locale.setDefault(hostLocale) + } + } + + /** + * 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 (the 150 ms suggestion debounce). + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun build(setup: (idle: () -> Unit) -> T): T { + val dispatcher = UnconfinedTestDispatcher() + Dispatchers.setMain(dispatcher) + val out = mutableListOf() + try { + runTest(dispatcher) { out += setup { advanceUntilIdle() } } + } finally { + Dispatchers.resetMain() + } + return out.single() + } +}