Coverage Summary for Class: DashboardLayoutConfig (com.georgeci.moneysurfer.domain.dashboard)

Class Method, % Branch, % Line, % Instruction, %
DashboardLayoutConfig 100% (10/10) 92.5% (37/40) 100% (50/50) 99.4% (506/509)
DashboardLayoutConfig$Companion 100% (1/1) 100% (1/1) 100% (1/1)
Total 100% (11/11) 92.5% (37/40) 100% (51/51) 99.4% (507/510)


 package com.georgeci.moneysurfer.domain.dashboard
 
 /**
  * The dashboard layout: an ordered list of widgets. Order in [items] *is* the render order.
  *
  * At compact width that order is the whole story — one widget per row, top to bottom. From expanded
  * width up the same order is packed into a [DashboardWidgetSpan.COLUMNS]-column grid, greedily: a
  * widget joins the current row if its [DashboardLayoutItem.span] still fits, and starts a new one if
  * it does not. There are still no rows to reconcile — a row is whatever the order and the spans add
  * up to, so reordering or resizing one widget can never orphan another.
  *
  * Stored per device (see `UiPreferences.dashboardLayout`), never synced — a layout is a property
  * of the screen the user is holding, not of the workspace.
  */
 data class DashboardLayoutConfig(
     val items: List<DashboardLayoutItem>,
 ) {
 
     /** What the dashboard actually renders, in order. */
     val enabledItems: List<DashboardLayoutItem>
         get() = items.filter { it.enabled }
 
     /** The widgets the user switched off — the "Available" half of the customize screen. */
     val disabledItems: List<DashboardLayoutItem>
         get() = items.filterNot { it.enabled }
 
     /**
      * Whether anything on screen reads [DashboardPeriod]. The period switch is chrome for the
      * widgets under it, so a layout that shows none of them shows no switch either — a Week/Month
      * control that visibly changes nothing reads as a broken one.
      */
     val hasPeriodScopedWidget: Boolean
         get() = enabledItems.any { it.type.isPeriodScoped }
 
     /**
      * [type] switched on or off, keeping the item either way so its card style survives the round
      * trip. Switching on appends the widget after the last enabled one — it joins the bottom of the
      * dashboard, rather than reappearing in a middle slot the user cannot see from the toggle.
      * Switching off sends it to the end of [items], so the enabled block stays contiguous.
      *
      * A [type] the layout does not carry is a no-op: only [normalized] adds items.
      */
     fun withWidgetEnabled(type: DashboardWidgetType, enabled: Boolean): DashboardLayoutConfig {
         val target = items.firstOrNull { it.type == type } ?: return this
         if (target.enabled == enabled) return this
         val others = items.filterNot { it.type == type }
         val moved = target.copy(enabled = enabled)
         val stillEnabled = others.filter { it.enabled }
         val stillDisabled = others.filterNot { it.enabled }
         return DashboardLayoutConfig(
             items = if (enabled) {
                 stillEnabled + moved + stillDisabled
             } else {
                 stillEnabled + stillDisabled + moved
             },
         )
     }
 
     /**
      * [from] dragged onto the slot [to] currently occupies. Positions are expressed as widget types
      * rather than indices because the customize screen drags rows out of a list that also holds
      * headers and the switched-off section — indices there are not layout positions.
      *
      * The two sections are one draggable list: dropping a widget onto a slot in the *other* section
      * moves it there, which is the same edit the +/− button makes. So a drop adopts the target's
      * [DashboardLayoutItem.enabled], and the enabled block stays contiguous either way — the row
      * lands where the finger left it, on the side of the boundary it was dropped.
      */
     fun withWidgetMoved(from: DashboardWidgetType, to: DashboardWidgetType): DashboardLayoutConfig {
         if (from == to) return this
         // Rendered order, not storage order: enabled first, switched-off under them.
         val ordered = (enabledItems + disabledItems).toMutableList()
         val fromIndex = ordered.indexOfFirst { it.type == from }
         val toIndex = ordered.indexOfFirst { it.type == to }
         if (fromIndex < 0 || toIndex < 0) return this
         val landsEnabled = ordered[toIndex].enabled
         ordered.add(toIndex, ordered.removeAt(fromIndex).copy(enabled = landsEnabled))
         return DashboardLayoutConfig(items = ordered)
     }
 
     /**
      * [type] restyled. Order and enabled-ness are left alone — the style picker only changes how a
      * widget looks, and it can be opened for a widget that sits anywhere in the list. Like
      * [withWidgetEnabled], naming a [type] the layout does not carry is a no-op.
      */
     fun withCardStyle(type: DashboardWidgetType, cardStyle: DashboardCardStyle): DashboardLayoutConfig {
         val target = items.firstOrNull { it.type == type } ?: return this
         if (target.cardStyle == cardStyle) return this
         return DashboardLayoutConfig(
             items = items.map { if (it.type == type) it.copy(cardStyle = cardStyle) else it },
         )
     }
 
     /**
      * [type] rewidened or narrowed for the grid. Like [withCardStyle] this touches nothing else:
      * a span is read only from expanded width up, and changing one widget's width there must not
      * shuffle the order the compact dashboard renders. Naming a [type] the layout does not carry is
      * a no-op.
      */
     fun withSpan(type: DashboardWidgetType, span: DashboardWidgetSpan): DashboardLayoutConfig {
         val target = items.firstOrNull { it.type == type } ?: return this
         if (target.span == span) return this
         return DashboardLayoutConfig(
             items = items.map { if (it.type == type) it.copy(span = span) else it },
         )
     }
 
     /**
      * A layout safe to render: at most one entry per widget type, with any type missing from
      * [items] appended in its [DEFAULT] position and style. Persisted layouts written by an older
      * app version know nothing about widgets added since, and dropping those silently would make
      * a new widget invisible until the user resets the dashboard.
      */
     fun normalized(): DashboardLayoutConfig {
         val unique = items.distinctBy { it.type }
         val present = unique.mapTo(mutableSetOf()) { it.type }
         val missing = DEFAULT.items.filterNot { it.type in present }
         return if (unique.size == items.size && missing.isEmpty()) this else DashboardLayoutConfig(unique + missing)
     }
 
     companion object {
         /**
          * Variant A from the design: the balance headline first, the quick actions under it, the
          * month's spend against its budget, then safe-to-spend, the burn rate that explains it, the
          * budgets both are read off, where the period's money went, the accounts strip, the same
          * spend as a donut, the payments still to come, the generated insights, goals, and the
          * recent-transactions list. Every widget starts enabled and Hero-sized.
          *
          * The spans are that same order read as the desktop mock's bands: a hero balance beside the
          * shortcuts, the spent-this-month band full width under them, a row of three stat cards, the
          * spend chart with the accounts rail next to it, the donut and the upcoming payments on
          * bands of their own, insights and goals paired, and the activity list full width at the bottom. Each row sums to
          * [DashboardWidgetSpan.COLUMNS] exactly, so the default grid has no gaps.
          *
          * Both new bands take [DashboardWidgetSpan.Full] for the same reason: it is the only span
          * that seats a widget without renarrowing the cards around it, which would have changed the
          * default dashboard of every user who never opened the customize screen. The donut is also a
          * wide row by nature (chart beside legend), and the spent-this-month card is the headline
          * figure the rest of the spend band is read against.
          */
         val DEFAULT = DashboardLayoutConfig(
             items = listOf(
                 DashboardLayoutItem(DashboardWidgetType.Balance, span = DashboardWidgetSpan.TwoThirds),
                 DashboardLayoutItem(DashboardWidgetType.QuickActions, span = DashboardWidgetSpan.Third),
                 DashboardLayoutItem(DashboardWidgetType.SpentMonth),
                 DashboardLayoutItem(DashboardWidgetType.SafeToSpend, span = DashboardWidgetSpan.Third),
                 DashboardLayoutItem(DashboardWidgetType.BurnRate, span = DashboardWidgetSpan.Third),
                 DashboardLayoutItem(DashboardWidgetType.Budgets, span = DashboardWidgetSpan.Third),
                 DashboardLayoutItem(DashboardWidgetType.SpentByCategory, span = DashboardWidgetSpan.TwoThirds),
                 DashboardLayoutItem(DashboardWidgetType.Accounts, span = DashboardWidgetSpan.Third),
                 DashboardLayoutItem(DashboardWidgetType.CategoriesDonut, span = DashboardWidgetSpan.Full),
                 DashboardLayoutItem(DashboardWidgetType.Recurring, span = DashboardWidgetSpan.Full),
                 DashboardLayoutItem(DashboardWidgetType.Insights, span = DashboardWidgetSpan.Half),
                 DashboardLayoutItem(DashboardWidgetType.Goals, span = DashboardWidgetSpan.Half),
                 DashboardLayoutItem(DashboardWidgetType.RecentTransactions),
             ),
         )
     }
 }