Coverage Summary for Class: CategoryManageUi (com.georgeci.moneysurfer.feature.category.manage)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CategoryManageUi |
100%
(1/1)
|
100%
(1/1)
|
|
100%
(9/9)
|
100%
(83/83)
|
package com.georgeci.moneysurfer.feature.category.manage
import arrow.optics.optics
import com.georgeci.moneysurfer.domain.model.CategoryNode
import com.georgeci.moneysurfer.domain.model.CategoryTree
import com.georgeci.moneysurfer.domain.primitives.CategoryId
import com.georgeci.moneysurfer.domain.primitives.CategoryType
import com.georgeci.moneysurfer.domain.usecase.CreateCategoryUseCase
import com.georgeci.moneysurfer.domain.usecase.DeleteCategoryUseCase
import com.georgeci.moneysurfer.domain.usecase.EditCategoryUseCase
import com.georgeci.moneysurfer.domain.usecase.GetCategoriesUseCase
import com.georgeci.moneysurfer.navigation.SnackbarController
import com.georgeci.moneysurfer.utils.MviViewModel
import moneysurfer.feature.category.generated.resources.Res
import moneysurfer.feature.category.generated.resources.categories_manage_delete_undo
import moneysurfer.feature.category.generated.resources.categories_manage_deleted_snackbar
import org.koin.core.annotation.KoinViewModel
@KoinViewModel
class CategoriesManageViewModel(
private val getCategories: GetCategoriesUseCase,
private val deleteCategory: DeleteCategoryUseCase,
private val createCategory: CreateCategoryUseCase,
private val editCategory: EditCategoryUseCase,
private val snackbar: SnackbarController,
) : MviViewModel<CategoriesManageState, CategoriesManageEvent, CategoriesManageEffect>(
initialState = CategoriesManageState.Loading,
) {
init {
observeCategories()
}
override fun onEvent(event: CategoriesManageEvent) {
when (event) {
CategoriesManageEvent.OnBackClick -> postSideEffect(CategoriesManageEffect.NavigateBack)
CategoriesManageEvent.OnAddCategoryClick -> postSideEffect(
CategoriesManageEffect.NavigateToCategoryCreation,
)
is CategoriesManageEvent.OnCategoryClick ->
postSideEffect(CategoriesManageEffect.NavigateToCategoryDetails(event.categoryId))
is CategoriesManageEvent.OnEditCategoryClick ->
postSideEffect(CategoriesManageEffect.NavigateToCategoryEdit(event.categoryId))
is CategoriesManageEvent.OnRemoveCategoryClick -> requestDelete(event.categoryId)
CategoriesManageEvent.OnDeleteCancel -> dismissDelete()
CategoriesManageEvent.OnDeleteConfirm -> confirmDelete()
}
}
private fun requestDelete(categoryId: CategoryId) {
val content = currentState as? CategoriesManageState.Content ?: return
val target = content.categories.firstOrNull { it.id == categoryId } ?: return
updateState {
CategoriesManageState.content.pendingDelete.modify(this) {
CategoriesManagePendingDelete(
id = target.id,
name = target.name,
// Drives the extra line in the dialog: children survive the delete, they
// just move up to the root, and the user should know that before tapping.
childCount = content.categories.count { it.parentId == target.id },
)
}
}
}
private fun dismissDelete() = updateState {
CategoriesManageState.content.pendingDelete.modify(this) { null }
}
private fun confirmDelete() {
val content = currentState as? CategoriesManageState.Content ?: return
val target = content.pendingDelete ?: return
updateState { CategoriesManageState.content.pendingDelete.modify(this) { null } }
launch {
val deletion = deleteCategory(target.id) ?: return@launch
snackbar.show(
message = Res.string.categories_manage_deleted_snackbar,
messageArgs = listOf(deletion.category.name),
actionLabel = Res.string.categories_manage_delete_undo,
onAction = {
// Restore the parent first — the children carry a foreign key back to it,
// so re-attaching them before it exists would be rejected.
createCategory(deletion.category)
deletion.reparentedChildren.forEach { editCategory(it) }
},
)
}
}
private fun observeCategories() {
launch {
getCategories().collect { categories ->
val rows = CategoryTree.flatten(categories).map { it.toUi() }
updateState {
when (this) {
is CategoriesManageState.Loading -> CategoriesManageState.Content(categories = rows)
is CategoriesManageState.Content -> copy(categories = rows)
}
}
}
}
}
private fun CategoryNode.toUi() = CategoryManageUi(
id = category.id,
name = category.name,
type = category.type,
parentId = category.parentId,
iconKey = category.iconKey,
hue = category.hue,
systemKind = category.systemKind?.name,
depth = depth,
)
}
@optics
sealed interface CategoriesManageState {
data object Loading : CategoriesManageState
@optics
data class Content(
val categories: List<CategoryManageUi>,
val pendingDelete: CategoriesManagePendingDelete? = null,
) : CategoriesManageState {
companion object
}
companion object
}
data class CategoriesManagePendingDelete(
val id: CategoryId,
val name: String,
/** How many children will be moved up to the root if this delete goes through. */
val childCount: Int = 0,
)
/** One row of the manage list, already placed in the tree by [CategoryTree.flatten]. */
data class CategoryManageUi(
val id: CategoryId,
val name: String,
val type: CategoryType,
val parentId: CategoryId? = null,
val iconKey: String = "",
val hue: Int = -1,
val systemKind: String? = null,
/** 0 for a root, 1 for a child. Drives the row's indent. */
val depth: Int = 0,
)
sealed interface CategoriesManageEvent {
data object OnBackClick : CategoriesManageEvent
data object OnAddCategoryClick : CategoriesManageEvent
data class OnCategoryClick(val categoryId: CategoryId) : CategoriesManageEvent
data class OnEditCategoryClick(val categoryId: CategoryId) : CategoriesManageEvent
data class OnRemoveCategoryClick(val categoryId: CategoryId) : CategoriesManageEvent
data object OnDeleteConfirm : CategoriesManageEvent
data object OnDeleteCancel : CategoriesManageEvent
}
sealed interface CategoriesManageEffect {
data object NavigateBack : CategoriesManageEffect
data object NavigateToCategoryCreation : CategoriesManageEffect
data class NavigateToCategoryDetails(val categoryId: CategoryId) : CategoriesManageEffect
data class NavigateToCategoryEdit(val categoryId: CategoryId) : CategoriesManageEffect
}