Coverage Summary for Class: DashboardCustomizeViewModel (com.georgeci.moneysurfer.feature.dashboard.customize)

Class Method, % Branch, % Line, % Instruction, %
DashboardCustomizeViewModel 100% (4/4) 81.2% (13/16) 100% (25/25) 98.8% (163/165)
DashboardCustomizeViewModel$1 100% (1/1) 100% (1/1) 100% (20/20)
DashboardCustomizeViewModel$1$1 100% (1/1) 100% (1/1) 100% (7/7)
DashboardCustomizeViewModel$seedLayout$1 100% (1/1) 100% (2/2) 100% (30/30)
Total 100% (7/7) 81.2% (13/16) 100% (29/29) 99.1% (220/222)


 package com.georgeci.moneysurfer.feature.dashboard.customize
 
 import com.georgeci.moneysurfer.domain.config.HostCapabilities
 import com.georgeci.moneysurfer.domain.dashboard.DashboardCardStyle
 import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutConfig
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetSpan
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetType
 import com.georgeci.moneysurfer.domain.preferences.UiPreferences
 import com.georgeci.moneysurfer.utils.AsyncState
 import com.georgeci.moneysurfer.utils.MviViewModel
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.filterNotNull
 import kotlinx.coroutines.flow.first
 import org.koin.core.annotation.KoinViewModel
 
 @KoinViewModel
 class DashboardCustomizeViewModel(
     private val uiPreferences: UiPreferences,
     hostCapabilities: HostCapabilities,
 ) : MviViewModel<AsyncState<DashboardLayoutConfig>, DashboardCustomizeEvent, DashboardCustomizeEffect>(
     initialState = AsyncState.Loading,
 ) {
 
     /**
      * Whether the rows offer the per-widget card-style picker. Not part of the state: it is a
      * build-time fact that cannot change while the screen is open, and the state here is the
      * layout being edited.
      */
     val widgetStyleEnabled: Boolean = hostCapabilities.dashboardWidgetStyle
 
     /**
      * The layout waiting to be written, or null while nothing has been edited. A drag produces a
      * move every half row, and firing an independent write coroutine per move would let two of them
      * reach the store out of order and leave the older layout on disk. One writer draining a
      * conflated flow keeps the last edit last, and drops the intermediate layouts nobody needs.
      */
     private val pendingWrite = MutableStateFlow<DashboardLayoutConfig?>(null)
 
     init {
         seedLayout()
         launch {
             pendingWrite.filterNotNull().collect { uiPreferences.dashboardLayout.set(it) }
         }
     }
 
     override fun onEvent(event: DashboardCustomizeEvent) {
         when (event) {
             DashboardCustomizeEvent.OnBackClick -> postSideEffect(DashboardCustomizeEffect.NavigateBack)
             is DashboardCustomizeEvent.OnWidgetEnabledChange ->
                 edit { it.withWidgetEnabled(event.type, event.enabled) }
             is DashboardCustomizeEvent.OnWidgetMove ->
                 edit { it.withWidgetMoved(from = event.from, to = event.to) }
             is DashboardCustomizeEvent.OnCardStyleChange ->
                 edit { it.withCardStyle(event.type, event.cardStyle) }
             is DashboardCustomizeEvent.OnSpanChange ->
                 edit { it.withSpan(event.type, event.span) }
         }
     }
 
     /**
      * Read once instead of collected. This screen is the only writer of the layout, and every edit
      * below already updates the state it just persisted; staying subscribed would let the store
      * echo an earlier write back mid-drag and shuffle the rows out from under the finger.
      */
     private fun seedLayout() {
         launch {
             val stored = uiPreferences.dashboardLayout.flow.first().normalized()
             updateState { AsyncState.Content(stored) }
         }
     }
 
     /**
      * Applies [transform] to the layout on screen and persists the result. The state moves first so
      * a drag tracks the finger at frame rate rather than at the speed of a disk write.
      */
     private fun edit(transform: (DashboardLayoutConfig) -> DashboardLayoutConfig) {
         val current = (currentState as? AsyncState.Content)?.value ?: return
         val updated = transform(current)
         if (updated == current) return
         updateState { AsyncState.Content(updated) }
         pendingWrite.value = updated
     }
 }
 
 sealed interface DashboardCustomizeEvent {
     data object OnBackClick : DashboardCustomizeEvent
     data class OnWidgetEnabledChange(val type: DashboardWidgetType, val enabled: Boolean) : DashboardCustomizeEvent
 
     /** [from] was dragged onto the dashboard slot [to] holds right now. */
     data class OnWidgetMove(val from: DashboardWidgetType, val to: DashboardWidgetType) : DashboardCustomizeEvent
 
     /** A size or variant was picked for [type] in the card-style sheet. */
     data class OnCardStyleChange(
         val type: DashboardWidgetType,
         val cardStyle: DashboardCardStyle,
     ) : DashboardCustomizeEvent
 
     /**
      * A grid width was picked for [type]. Separate from [OnCardStyleChange] because a span is
      * placement rather than styling — see [com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutItem].
      */
     data class OnSpanChange(
         val type: DashboardWidgetType,
         val span: DashboardWidgetSpan,
     ) : DashboardCustomizeEvent
 }
 
 sealed interface DashboardCustomizeEffect {
     data object NavigateBack : DashboardCustomizeEffect
 }