Coverage Summary for Class: DashboardLayoutCodec (com.georgeci.moneysurfer.data.preferences)

Class Class, % Method, % Branch, % Line, % Instruction, %
DashboardLayoutCodec 100% (1/1) 100% (8/8) 100% (36/36) 100% (37/37) 100% (352/352)


 package com.georgeci.moneysurfer.data.preferences
 
 import com.georgeci.moneysurfer.domain.dashboard.DashboardCardStyle
 import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutConfig
 import com.georgeci.moneysurfer.domain.dashboard.DashboardLayoutItem
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetSize
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetSpan
 import com.georgeci.moneysurfer.domain.dashboard.DashboardWidgetType
 
 /**
  * Flat string encoding of a [DashboardLayoutConfig], one DataStore key instead of a nested
  * document:
  *
  * ```text
  * Balance:1:Expanded::TwoThirds|Accounts:1:Compact:strip|Goals:0:Expanded
  * ```
  *
  * `type:enabled:size[:variant[:span]]`, items separated by `|`. Types, sizes and spans are enum
  * names, but `variant` is free-form widget-defined text, so the separators (and the escape character
  * itself) are percent-escaped on the way in and restored on the way out — a variant containing `|`
  * would otherwise re-split into a bogus extra item.
  *
  * The two trailing fields are written only when they carry something: a widget at the default span
  * and no variant still encodes as the three fields it always did, and a span needs an (possibly
  * empty) variant field ahead of it to hold its position. Layouts written before spans existed
  * therefore decode unchanged, at [DashboardWidgetSpan.Full].
  *
  * Decoding never throws and never fails the read: anything unparseable is skipped and
  * [DashboardLayoutConfig.normalized] fills the gaps from the default layout, so a store written by
  * a newer build (or corrupted on disk) degrades to a usable dashboard rather than an empty one.
  */
 internal object DashboardLayoutCodec {
 
     private const val ITEM_SEPARATOR = '|'
     private const val FIELD_SEPARATOR = ':'
     private const val ENABLED = "1"
     private const val DISABLED = "0"
     private const val MIN_FIELDS = 3
     private const val VARIANT_FIELD = 3
     private const val SPAN_FIELD = 4
     private const val MAX_FIELDS = 5
 
     /** Escape char first on the way in, last on the way out — otherwise escapes eat each other. */
     private val VARIANT_ESCAPES = listOf(
         "%" to "%25",
         ITEM_SEPARATOR.toString() to "%7C",
         FIELD_SEPARATOR.toString() to "%3A",
     )
 
     fun encode(config: DashboardLayoutConfig): String =
         config.items.joinToString(ITEM_SEPARATOR.toString()) { item ->
             buildString {
                 append(item.type.name)
                 append(FIELD_SEPARATOR)
                 append(if (item.enabled) ENABLED else DISABLED)
                 append(FIELD_SEPARATOR)
                 append(item.cardStyle.size.name)
                 val span = item.span.takeIf { it != DashboardWidgetSpan.Full }
                 if (item.cardStyle.variant != null || span != null) {
                     append(FIELD_SEPARATOR)
                     append(item.cardStyle.variant?.let(::escapeVariant).orEmpty())
                 }
                 if (span != null) {
                     append(FIELD_SEPARATOR)
                     append(span.name)
                 }
             }
         }
 
     private fun escapeVariant(variant: String): String =
         VARIANT_ESCAPES.fold(variant) { acc, (raw, escaped) -> acc.replace(raw, escaped) }
 
     private fun unescapeVariant(variant: String): String =
         VARIANT_ESCAPES.reversed().fold(variant) { acc, (raw, escaped) -> acc.replace(escaped, raw) }
 
     fun decode(stored: String): DashboardLayoutConfig = decodeOrNull(stored) ?: DashboardLayoutConfig.DEFAULT
 
     /**
      * `null` when nothing in [stored] parsed, so a caller that needs to tell "the user is on the
      * default layout" from "the stored string is unreadable" can. The configuration engine does:
      * reporting garbage as a decoded DEFAULT would make that layer *win*, hiding the corruption
      * from the debug panel and overwriting the string on the next save.
      */
     fun decodeOrNull(stored: String): DashboardLayoutConfig? {
         if (stored.isBlank()) return null
         val items = stored.split(ITEM_SEPARATOR).mapNotNull(::decodeItem)
         return if (items.isEmpty()) null else DashboardLayoutConfig(items).normalized()
     }
 
     private fun decodeItem(stored: String): DashboardLayoutItem? {
         val fields = stored.split(FIELD_SEPARATOR, limit = MAX_FIELDS)
         if (fields.size < MIN_FIELDS) return null
         val type = DashboardWidgetType.entries.firstOrNull { it.name == fields[0] } ?: return null
         val size = DashboardWidgetSize.entries.firstOrNull { it.name == fields[2] } ?: DashboardWidgetSize.Expanded
         val span = DashboardWidgetSpan.entries.firstOrNull { it.name == fields.getOrNull(SPAN_FIELD) }
         return DashboardLayoutItem(
             type = type,
             enabled = fields[1] != DISABLED,
             cardStyle = DashboardCardStyle(
                 size = size,
                 variant = fields.getOrNull(VARIANT_FIELD)?.takeIf(String::isNotBlank)?.let(::unescapeVariant),
             ),
             span = span ?: DashboardWidgetSpan.Full,
         )
     }
 }