Coverage Summary for Class: BudgetsEvent (com.georgeci.moneysurfer.feature.budget.list)

Class Method, % Branch, % Line, % Instruction, %
BudgetsEvent$OnAddBudgetClick 100% (1/1) 100% (1/1) 100% (2/2)
BudgetsEvent$OnArchiveClick 100% (1/1) 100% (1/1) 100% (7/7)
BudgetsEvent$OnBackClick 100% (1/1) 100% (1/1) 100% (2/2)
BudgetsEvent$OnBudgetClick 0% (0/1) 0% (0/1) 0% (0/7)
BudgetsEvent$OnDeleteCancel 100% (1/1) 100% (1/1) 100% (2/2)
BudgetsEvent$OnDeleteClick 100% (1/1) 100% (1/1) 100% (7/7)
BudgetsEvent$OnDeleteConfirm 100% (1/1) 100% (1/1) 100% (2/2)
BudgetsEvent$OnRestoreClick 0% (0/1) 0% (0/1) 0% (0/7)
Total 75% (6/8) 75% (6/8) 61.1% (22/36)


 package com.georgeci.moneysurfer.feature.budget.list
 
 import arrow.optics.optics
 import com.georgeci.moneysurfer.domain.auth.SessionPointers
 import com.georgeci.moneysurfer.domain.primitives.BudgetId
 import com.georgeci.moneysurfer.domain.usecase.ArchiveBudgetUseCase
 import com.georgeci.moneysurfer.domain.usecase.DeleteBudgetUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetBudgetProgressUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetBudgetsUseCase
 import com.georgeci.moneysurfer.domain.usecase.GetCategoriesUseCase
 import com.georgeci.moneysurfer.feature.budget.BudgetUi
 import com.georgeci.moneysurfer.feature.budget.toUi
 import com.georgeci.moneysurfer.navigation.SnackbarController
 import com.georgeci.moneysurfer.utils.MviViewModel
 import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.flatMapLatest
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.map
 import moneysurfer.feature.budget.generated.resources.Res
 import moneysurfer.feature.budget.generated.resources.budgets_archive_undo
 import moneysurfer.feature.budget.generated.resources.budgets_archived_snackbar
 import moneysurfer.feature.budget.generated.resources.budgets_deleted_snackbar
 import org.koin.core.annotation.KoinViewModel
 
 @KoinViewModel
 @OptIn(ExperimentalCoroutinesApi::class)
 class BudgetsViewModel(
     private val getBudgets: GetBudgetsUseCase,
     private val getBudgetProgress: GetBudgetProgressUseCase,
     private val getCategories: GetCategoriesUseCase,
     private val archiveBudget: ArchiveBudgetUseCase,
     private val deleteBudget: DeleteBudgetUseCase,
     private val session: SessionPointers,
     private val snackbar: SnackbarController,
 ) : MviViewModel<BudgetsState, BudgetsEvent, BudgetsEffect>(initialState = BudgetsState.Loading) {
 
     init {
         observeBudgets()
     }
 
     override fun onEvent(event: BudgetsEvent) {
         when (event) {
             BudgetsEvent.OnBackClick -> postSideEffect(BudgetsEffect.NavigateBack)
             BudgetsEvent.OnAddBudgetClick -> postSideEffect(BudgetsEffect.NavigateToBudgetCreation)
             is BudgetsEvent.OnBudgetClick -> postSideEffect(BudgetsEffect.NavigateToBudgetDetails(event.budgetId))
             is BudgetsEvent.OnArchiveClick -> setActive(event.budgetId, isActive = false)
             is BudgetsEvent.OnRestoreClick -> setActive(event.budgetId, isActive = true)
             is BudgetsEvent.OnDeleteClick -> requestDelete(event.budgetId)
             BudgetsEvent.OnDeleteCancel -> dismissDelete()
             BudgetsEvent.OnDeleteConfirm -> confirmDelete()
         }
     }
 
     private fun setActive(budgetId: BudgetId, isActive: Boolean) {
         val name = nameOf(budgetId)
         launch {
             archiveBudget(budgetId, isActive)
             if (!isActive && name != null) {
                 snackbar.show(
                     message = Res.string.budgets_archived_snackbar,
                     messageArgs = listOf(name),
                     actionLabel = Res.string.budgets_archive_undo,
                     onAction = { archiveBudget(budgetId, isActive = true) },
                 )
             }
         }
     }
 
     private fun requestDelete(budgetId: BudgetId) {
         val name = nameOf(budgetId) ?: return
         updateState {
             BudgetsState.content.pendingDelete.modify(this) { BudgetsPendingDelete(budgetId, name) }
         }
     }
 
     private fun dismissDelete() = updateState {
         BudgetsState.content.pendingDelete.modify(this) { null }
     }
 
     private fun confirmDelete() {
         val target = (currentState as? BudgetsState.Content)?.pendingDelete ?: return
         updateState { BudgetsState.content.pendingDelete.modify(this) { null } }
         launch {
             deleteBudget(target.id) ?: return@launch
             snackbar.show(Res.string.budgets_deleted_snackbar, listOf(target.name))
         }
     }
 
     private fun nameOf(budgetId: BudgetId): String? {
         val content = currentState as? BudgetsState.Content ?: return null
         return (content.active + content.archived).firstOrNull { it.id == budgetId }?.name
     }
 
     /**
      * One subscription for every card: budgets and categories drive a single progress query, so
      * adding a budget costs a recomposition rather than another transaction read per card.
      */
     private fun observeBudgets() {
         launch {
             session.currentWorkspaceId
                 .flatMapLatest { workspaceId ->
                     if (workspaceId == null) {
                         flowOf(emptyList())
                     } else {
                         combine(getBudgets(), getCategories()) { budgets, categories -> budgets to categories }
                             .flatMapLatest { (budgets, categories) ->
                                 val categoriesById = categories.associateBy { it.id.value }
                                 getBudgetProgress.progressOf(workspaceId, budgets)
                                     .map { progresses -> progresses.map { it.toUi(categoriesById) } }
                             }
                     }
                 }
                 .collect { budgets ->
                     updateState {
                         when (this) {
                             is BudgetsState.Loading -> BudgetsState.Content(
                                 active = budgets.filter { it.isActive },
                                 archived = budgets.filterNot { it.isActive },
                             )
                             is BudgetsState.Content -> copy(
                                 active = budgets.filter { it.isActive },
                                 archived = budgets.filterNot { it.isActive },
                             )
                         }
                     }
                 }
         }
     }
 }
 
 @optics
 sealed interface BudgetsState {
     data object Loading : BudgetsState
 
     @optics
     data class Content(
         val active: List<BudgetUi>,
         val archived: List<BudgetUi>,
         val pendingDelete: BudgetsPendingDelete? = null,
     ) : BudgetsState {
         companion object
     }
 
     companion object
 }
 
 data class BudgetsPendingDelete(
     val id: BudgetId,
     val name: String,
 )
 
 sealed interface BudgetsEvent {
     data object OnBackClick : BudgetsEvent
     data object OnAddBudgetClick : BudgetsEvent
     data class OnBudgetClick(val budgetId: BudgetId) : BudgetsEvent
     data class OnArchiveClick(val budgetId: BudgetId) : BudgetsEvent
     data class OnRestoreClick(val budgetId: BudgetId) : BudgetsEvent
     data class OnDeleteClick(val budgetId: BudgetId) : BudgetsEvent
     data object OnDeleteConfirm : BudgetsEvent
     data object OnDeleteCancel : BudgetsEvent
 }
 
 sealed interface BudgetsEffect {
     data object NavigateBack : BudgetsEffect
     data object NavigateToBudgetCreation : BudgetsEffect
     data class NavigateToBudgetDetails(val budgetId: BudgetId) : BudgetsEffect
 }