Coverage Summary for Class: CategoryAppearance (com.georgeci.moneysurfer.domain.model)
| Class |
Class, %
|
Method, %
|
Branch, %
|
Line, %
|
Instruction, %
|
| CategoryAppearance |
100%
(1/1)
|
100%
(12/12)
|
100%
(10/10)
|
100%
(21/21)
|
100%
(181/181)
|
package com.georgeci.moneysurfer.domain.model
import com.georgeci.moneysurfer.domain.primitives.CategorySystemKind
/**
* Semantic catalogue behind a category's stored `iconKey` and `hue`.
*
* Both are stored as *values*, never as palette indices: an index is positional, so inserting
* one icon into the middle of the picker grid would silently re-icon and recolour every
* category already saved on every device.
*
* The catalogue also supplies the deterministic default a category falls back to when it
* carries no stored appearance — a row written by an older client, or the additive
* `iconKey`/`hue` backfill in the Room migration. Deriving both from a stable hash of the
* category id keeps pre-existing data varied (a blank backfill would render every category in
* one identical colour) and stable across devices, and reproduces exactly what the UI drew
* before the fields existed.
*/
object CategoryAppearance {
/** Design palette hues in degrees, in colour-picker display order. */
val HUES: List<Int> = listOf(162, 35, 258, 68, 303, 340, 8, 90)
/** Icon keys in icon-picker display order. Positionally paired with [HUES] for defaults. */
val ICON_KEYS: List<String> = listOf(
"category",
"card",
"savings",
"cash",
"wallet",
"receipt",
"event",
"tag",
)
/** Appearance of the seeded system Transfer category. Fixed, so it is always recognisable. */
const val TRANSFER_ICON_KEY: String = "transfer"
const val TRANSFER_HUE: Int = 258
/** Sentinel stored by rows that predate the fields — resolved through [defaultHue]. */
const val UNSET_HUE: Int = -1
/** Exclusive upper bound of the hue circle. */
const val HUE_DEGREES: Int = 360
/** Whether [hue] is a usable stored value rather than the [UNSET_HUE] sentinel or junk. */
fun isStoredHue(hue: Int): Boolean = hue in 0 until HUE_DEGREES
/** Whether [iconKey] is a usable stored value rather than the blank sentinel. */
fun isStoredIconKey(iconKey: String): Boolean = iconKey.isNotBlank()
/**
* Deterministic icon key for a category that has none stored, derived from its id so the
* choice is stable across devices and identical to what the pre-field UI rendered.
*/
fun defaultIconKey(id: String, systemKind: CategorySystemKind? = null): String =
if (systemKind == CategorySystemKind.TRANSFER) TRANSFER_ICON_KEY else ICON_KEYS[paletteSlot(id)]
/** Deterministic hue for a category that has none stored. See [defaultIconKey]. */
fun defaultHue(id: String, systemKind: CategorySystemKind? = null): Int =
if (systemKind == CategorySystemKind.TRANSFER) TRANSFER_HUE else HUES[paletteSlot(id)]
/**
* Palette slot an id hashes into. Both defaults read the same slot, so a backfilled
* category keeps the icon/colour pairing the old index-based rendering gave it.
*/
fun paletteSlot(id: String): Int = nonNegativeMod(stableHash(id), HUES.size)
/**
* Stable string hash. Deliberately spelled out rather than delegating to `String.hashCode()`,
* which carries no cross-platform guarantee — a backfilled category must land in the same
* slot on Android and iOS or the same workspace renders two different colours.
*/
fun stableHash(value: String): Long = value.fold(0L) { acc, c -> acc * HASH_MULTIPLIER + c.code }
private fun nonNegativeMod(value: Long, size: Int): Int {
val bound = size.toLong()
val rem = value % bound
return (if (rem < 0) rem + bound else rem).toInt()
}
private const val HASH_MULTIPLIER = 31L
}