From 74b5fbdb59afd80b5533e9b4928495a42047a3a6 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 23:10:38 +0200 Subject: [PATCH] Android overhaul: S-7's state types in ExpenseEditorState.kt ExpenseEditorViewModel.kt (555 lines) held the editor's form, state, overlay and event types beside the ViewModel; they move unchanged to their own file (471 + 97 lines). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../feature/expense/ExpenseEditorState.kt | 97 +++++++++++++++++++ .../feature/expense/ExpenseEditorViewModel.kt | 84 ---------------- 2 files changed, 97 insertions(+), 84 deletions(-) create mode 100644 android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorState.kt diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorState.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorState.kt new file mode 100644 index 0000000..399fab2 --- /dev/null +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorState.kt @@ -0,0 +1,97 @@ +package se.hagstedt.kvitt.feature.expense + +import se.hagstedt.kvitt.design.component.AccountChoice +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.FieldError +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId + +// What S-7 renders and the one-off events of its state holder, ExpenseEditorViewModel. + +/** 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 +} 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 index d1a63f1..9c168d0 100644 --- 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 @@ -27,7 +27,6 @@ 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 @@ -37,93 +36,10 @@ 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).