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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 23:10:38 +02:00
parent a92c236689
commit 74b5fbdb59
2 changed files with 97 additions and 84 deletions
@@ -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<AccountChoice> = emptyList(),
val account: Account? = null,
val accountColorIndex: Int = 0,
val categories: List<Category> = emptyList(),
val quickRow: List<Category> = emptyList(),
val category: Category? = null,
val preview: List<Share>? = 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<FieldError> = 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
}
@@ -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<AccountChoice> = emptyList(),
val account: Account? = null,
val accountColorIndex: Int = 0,
val categories: List<Category> = emptyList(),
val quickRow: List<Category> = emptyList(),
val category: Category? = null,
val preview: List<Share>? = 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<FieldError> = 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).