Coverage Summary for Class: CategoryChooserViewModel (com.georgeci.moneysurfer.feature.category.picker)
| Class |
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CategoryChooserViewModel |
100%
(11/11)
|
100%
(34/34)
|
100%
(48/48)
|
100%
(369/369)
|
| CategoryChooserViewModel$observeCategories$1 |
100%
(1/1)
|
|
100%
(1/1)
|
100%
(19/19)
|
| CategoryChooserViewModel$observeCategories$1$1 |
100%
(2/2)
|
100%
(2/2)
|
100%
(6/6)
|
100%
(28/28)
|
| CategoryChooserViewModel$WhenMappings |
|
| Total |
100%
(14/14)
|
100%
(36/36)
|
100%
(55/55)
|
100%
(416/416)
|
package com.georgeci.moneysurfer.feature.category.picker
import arrow.optics.optics
import com.georgeci.moneysurfer.domain.model.Category
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CategoryType
import com.georgeci.moneysurfer.domain.usecase.GetCategoriesUseCase
import com.georgeci.moneysurfer.utils.MviViewModel
import org.koin.core.annotation.KoinViewModel
@KoinViewModel
class CategoryChooserViewModel(
initialSelectedId: CategoryId?,
private val filterType: CategoryType?,
private val initialVariant: CategoryPickerVariant,
private val getCategories: GetCategoriesUseCase,
) : MviViewModel<CategoryChooserState, CategoryChooserEvent, CategoryChooserEffect>(
initialState = CategoryChooserState.Loading(selectedId = initialSelectedId),
) {
/**
* Every category of every type, unfiltered. The tab row switches type without another read,
* and the state only ever carries what the current tab renders.
*/
private var allCategories: List<Category> = emptyList()
init {
observeCategories()
}
override fun onEvent(event: CategoryChooserEvent) {
when (event) {
is CategoryChooserEvent.OnCategorySelected ->
postSideEffect(CategoryChooserEffect.PublishResult(event.id))
CategoryChooserEvent.OnCreateNewCategoryClick ->
postSideEffect(CategoryChooserEffect.NavigateToCategoryCreation)
CategoryChooserEvent.OnDismiss -> postSideEffect(CategoryChooserEffect.Dismiss)
is CategoryChooserEvent.OnTypeSelected -> updateContent {
// Expansion is per-parent and the parents differ per type, so a stale set would
// only ever open the wrong rows.
copy(type = event.type, expandedIds = expandedFor(event.type))
}
is CategoryChooserEvent.OnQueryChange -> updateContent { copy(query = event.query) }
CategoryChooserEvent.OnVariantToggle -> updateContent { copy(variant = variant.other()) }
is CategoryChooserEvent.OnParentExpandToggle -> updateContent {
copy(
expandedIds = if (event.id in expandedIds) {
expandedIds - event.id
} else {
expandedIds + event.id
},
)
}
}
}
private fun observeCategories() {
launch {
getCategories().collect { categories ->
allCategories = categories
updateState {
when (this) {
// Type, query and expansion are the user's; a re-emission from sync
// rebuilds the rows underneath them without resetting any of it.
is CategoryChooserState.Content -> this
is CategoryChooserState.Loading -> newContent()
}.rebuild()
}
}
}
}
private fun CategoryChooserState.Loading.newContent(): CategoryChooserState.Content {
val type = CategoryPickerRows.initialType(allCategories, selectedId, filterType)
return CategoryChooserState.Content(
selectedId = selectedId,
selectedName = null,
type = type,
typeLocked = filterType != null,
query = "",
expandedIds = expandedFor(type),
variant = initialVariant,
)
}
private fun CategoryPickerVariant.other(): CategoryPickerVariant = when (this) {
CategoryPickerVariant.GRID -> CategoryPickerVariant.TREE
CategoryPickerVariant.TREE -> CategoryPickerVariant.GRID
}
private fun expandedFor(type: CategoryType): Set<CategoryId> {
val selected = currentState.selectedId?.takeIf { id ->
allCategories.firstOrNull { it.id == id }?.type == type
}
return CategoryPickerRows.expandedForSelection(allCategories, selected)
}
/** Applies [reducer] once the sheet has content; events that land while loading are dropped. */
private fun updateContent(reducer: CategoryChooserState.Content.() -> CategoryChooserState.Content) =
updateState {
when (this) {
is CategoryChooserState.Content -> reducer().rebuild()
is CategoryChooserState.Loading -> this
}
}
/** Re-derives everything the sheet draws from the inputs the events just changed. */
private fun CategoryChooserState.Content.rebuild() = copy(
selectedName = allCategories.firstOrNull { it.id == selectedId }?.name,
gridItems = CategoryPickerRows.grid(allCategories, type, query, selectedId),
treeItems = CategoryPickerRows.tree(allCategories, type, query, selectedId, expandedIds),
)
}
@optics
sealed interface CategoryChooserState {
val selectedId: CategoryId?
@optics
data class Loading(override val selectedId: CategoryId?) : CategoryChooserState {
companion object
}
@optics
data class Content(
override val selectedId: CategoryId?,
/** Name behind [selectedId], for the `Currently: …` subline. Null until it resolves. */
val selectedName: String?,
val type: CategoryType,
/** True when the caller pinned the type — the tab row is then not offered at all. */
val typeLocked: Boolean,
val query: String,
val expandedIds: Set<CategoryId>,
val variant: CategoryPickerVariant,
val gridItems: List<CategoryPickerItem> = emptyList(),
val treeItems: List<CategoryTreeGroup> = emptyList(),
) : CategoryChooserState {
companion object
}
companion object
}
sealed interface CategoryChooserEvent {
data class OnCategorySelected(val id: CategoryId) : CategoryChooserEvent
data class OnTypeSelected(val type: CategoryType) : CategoryChooserEvent
data class OnQueryChange(val query: String) : CategoryChooserEvent
data class OnParentExpandToggle(val id: CategoryId) : CategoryChooserEvent
data object OnVariantToggle : CategoryChooserEvent
data object OnCreateNewCategoryClick : CategoryChooserEvent
data object OnDismiss : CategoryChooserEvent
}
sealed interface CategoryChooserEffect {
data object NavigateToCategoryCreation : CategoryChooserEffect
data object Dismiss : CategoryChooserEffect
data class PublishResult(val id: CategoryId) : CategoryChooserEffect
}