M7f: feature settings, S-13 to S-15 and S-19 to S-22

Hub, profile (photo picker), categories (drag reorder, editor with D-059 delete reasons), recurring
expenses (list and S-7-style editor with schedule, AC-9.8 default start), devices, invites and about,
against the :core:model interfaces. Scenarios and scripted online failures in fakes/settings.
84 states screenshot-tested in sv/en x light/dark (336 goldens); state-holder unit tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 11:04:29 +02:00
parent 0b140aa3ad
commit c4ab35f66e
39 changed files with 5525 additions and 14 deletions
@@ -0,0 +1,102 @@
package se.hagstedt.kvitt.fakes.settings
import kotlinx.coroutines.awaitCancellation
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.UserId
import kotlinx.coroutines.flow.Flow
/** The code of the design spec's S-21 frame, so a created invite reads like a real one (§8.3 alphabet). */
const val SAMPLE_CODE = "7K3MQX9D2HPA"
/**
* What the next online calls of the scripted repositories do instead of the fake's answer: fail with [fail] (for
* example `Failed("network", null)` or `Failed("image_invalid", null)`), or never return while [hang] is set (an upload
* or a list request in progress). Null and false: the fake answers.
*/
class OnlineScript {
var fail: OnlineResult.Failed? = null
var hang: Boolean = false
suspend fun <T> run(call: suspend () -> OnlineResult<T>): OnlineResult<T> {
if (hang) awaitCancellation()
return fail ?: call()
}
}
/**
* The fakes of [FakeRepositories] with the online calls of the settings screens routed through one [script]: the
* error, loading and in-progress states of S-14, S-15, S-19, S-20, S-21 and S-22 (M7f). Reads pass straight through.
*/
class ScriptedSettingsRepositories(scenario: FakeScenario) {
val fakes = FakeRepositories(scenario)
val script = OnlineScript()
val profile: ProfileRepository = object : ProfileRepository by fakes.profile {
override suspend fun setDisplayName(name: String) = script.run { fakes.profile.setDisplayName(name) }
override suspend fun setPicture(bytes: ByteArray, mimeType: String) = script.run { fakes.profile.setPicture(bytes, mimeType) }
override suspend fun removePicture() = script.run { fakes.profile.removePicture() }
}
val categories: CategoryRepository = object : CategoryRepository by fakes.categories {
override suspend fun create(name: String, icon: String, color: ColorToken) = script.run { fakes.categories.create(name, icon, color) }
override suspend fun update(id: CategoryId, name: String?, icon: String, color: ColorToken) = script.run { fakes.categories.update(id, name, icon, color) }
override suspend fun reorder(idsInOrder: List<CategoryId>) = script.run { fakes.categories.reorder(idsInOrder) }
override suspend fun setArchived(id: CategoryId, archived: Boolean) = script.run { fakes.categories.setArchived(id, archived) }
override suspend fun delete(id: CategoryId) = script.run { fakes.categories.delete(id) }
override fun deletable(id: CategoryId): Flow<Boolean> = fakes.categories.deletable(id)
}
val recurring: RecurringRepository = object : RecurringRepository by fakes.recurring {
override suspend fun create(t: RecurringTemplate) = script.run { fakes.recurring.create(t) }
override suspend fun update(t: RecurringTemplate) = script.run { fakes.recurring.update(t) }
override suspend fun delete(id: TemplateId) = script.run { fakes.recurring.delete(id) }
}
val devices: DeviceRepository = object : DeviceRepository {
override suspend fun list(): OnlineResult<List<Device>> = script.run { fakes.devices.list() }
override suspend fun revoke(id: DeviceId): OnlineResult<Unit> = script.run { fakes.devices.revoke(id) }
}
val invites: InviteRepository = object : InviteRepository {
override suspend fun list(): OnlineResult<List<Invite>> = script.run { fakes.invites.list() }
override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite> = script.run {
when (val created = fakes.invites.create(kind, targetUser)) {
is OnlineResult.Ok -> OnlineResult.Ok(created.value.copy(code = SAMPLE_CODE, link = created.value.link?.substringBeforeLast('/') + "/" + SAMPLE_CODE))
else -> created
}
}
override suspend fun cancel(id: InviteId): OnlineResult<Unit> = script.run { fakes.invites.cancel(id) }
}
/** `check` of the fake leaves the state unchanged; [afterCheck] is the state a check finds (S-22 result states). */
var afterCheck: UpdateState? = null
val update: UpdateRepository = object : UpdateRepository {
override val state = fakes.update.state
override suspend fun check() {
if (script.hang) awaitCancellation()
afterCheck?.let { found -> fakes.store.edit { it.copy(update = found) } }
}
override suspend fun install() = fakes.update.install()
}
}
@@ -0,0 +1,210 @@
package se.hagstedt.kvitt.fakes.settings
import se.hagstedt.kvitt.fakes.FakeExpense
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UpdateInfo
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
/**
* Scenarios of `:feature:settings` (M7f; S-13 to S-15, S-19 to S-22), as in the design spec's frames: Jacob (admin) and
* Anna, the accounts Vardag, Huset and Japan 2027, the 14 defaults plus the archived custom "Pant", four recurring
* templates, both phones and one pending device invite. The phone's clock is 17 October 2026 14:02 Stockholm.
*/
object SettingsScenarios {
val jacob: User = FakeScenarios.owner
val anna: User = FakeScenarios.partner
val jacobPhone = DeviceId("01920000-0000-7000-8000-0000000000d1")
val annaPhone = DeviceId("01920000-0000-7000-8000-0000000000d2")
private val oldPhone = DeviceId("01920000-0000-7000-8000-0000000000d3")
val vardag = Account(AccountId("01920000-0000-7000-8000-0000000000b1"), "Vardag", mapOf(jacob.id to 5_000, anna.id to 5_000), "2026-01-03T10:00:00.000Z", archived = false)
val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", mapOf(jacob.id to 6_000, anna.id to 4_000), "2026-01-04T10:00:00.000Z", archived = false)
val japan = Account(AccountId("01920000-0000-7000-8000-0000000000b3"), "Japan 2027", mapOf(jacob.id to 5_000, anna.id to 5_000), "2026-05-01T10:00:00.000Z", archived = false)
private fun default(key: DefaultCategoryKey): Category = FakeScenarios.defaultCategories.first { it.defaultKey == key }
val groceries = default(DefaultCategoryKey.GROCERIES)
val housing = default(DefaultCategoryKey.HOUSING)
val utilities = default(DefaultCategoryKey.UTILITIES)
val entertainment = default(DefaultCategoryKey.ENTERTAINMENT)
/** A custom, archived category still used by an expense (S-15 editor "custom archived", AC-7.6 in use). */
val pant = Category(CategoryId("01920000-0000-7000-8000-0000000000c1"), null, "Pant", "water_drop", ColorToken.TEAL, 110, archived = true)
/** A custom, active category nothing uses: deletable. */
val hund = Category(CategoryId("01920000-0000-7000-8000-0000000000c2"), null, "Hunden", "pets", ColorToken.BROWN, 120, archived = false)
val categories: List<Category> = FakeScenarios.defaultCategories + hund + pant
/** The default category Groceries renamed "Matvaror" (S-15 editor, AC-7.4). */
val renamedCategories: List<Category> = categories.map { if (it.id == groceries.id) it.copy(customName = "Matvaror") else it }
private fun draft(account: Account, category: Category, ore: Long, description: String, payer: User, mode: SplitMode = SplitMode.DEFAULT, ratio: Map<UserId, Int>? = null, exact: Map<UserId, Long>? = null, fullOn: UserId? = null) =
ExpenseDraft(null, account.id, category.id, ore, description, "", "2026-10-17", payer.id, mode, ratio, exact, fullOn)
val hyra = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e1"), draft(huset, housing, 1_240_000, "Hyra", anna), 25, "2026-09", null, paused = false, nextOccurrence = "2026-10-25")
val bredband = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e2"), draft(huset, utilities, 39_900, "Bahnhof bredband", jacob, SplitMode.RATIO, ratio = mapOf(jacob.id to 5_000, anna.id to 5_000)), 28, "2026-01", null, paused = false, nextOccurrence = "2026-10-28")
val netflix = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e3"), draft(vardag, entertainment, 14_900, "Netflix", jacob, SplitMode.EXACT, exact = mapOf(jacob.id to 7_900, anna.id to 7_000)), 1, "2026-03", "2027-02", paused = false, nextOccurrence = "2026-11-01")
val spotify = RecurringTemplate(TemplateId("01920000-0000-7000-8000-0000000000e4"), draft(vardag, entertainment, 23_900, "Spotify Family", anna, SplitMode.FULL, fullOn = anna.id), 12, "2025-11", null, paused = true, nextOccurrence = null)
private val pantExpense = FakeExpense(
id = ExpenseId("01920000-0000-7000-8000-0000000000f1"),
accountId = vardag.id,
categoryId = pant.id,
amountOre = 4_000,
description = "Pant Willys",
notes = "",
date = "2026-08-02",
payer = jacob.id,
mode = SplitMode.DEFAULT,
shares = listOf(Share(jacob.id, 2_000, 5_000), Share(anna.id, 2_000, 5_000)),
createdAt = "2026-08-02T10:00:00.000Z",
)
private val pendingExpense = pantExpense.copy(
id = ExpenseId("01920000-0000-7000-8000-0000000000f2"),
categoryId = groceries.id,
amountOre = 124_760,
description = "ICA Maxi Lindhagen",
date = "2026-10-17",
createdAt = "2026-10-17T11:30:00.000Z",
shares = listOf(Share(jacob.id, 62_380, 5_000), Share(anna.id, 62_380, 5_000)),
pending = true,
)
private val devices = listOf(
Device(jacobPhone, jacob, "Galaxy S26", "2026-10-03T08:00:00.000Z", "2026-10-17T12:01:40.000Z", 11, current = true, revoked = false),
Device(annaPhone, anna, "Galaxy S26+", "2026-10-03T08:20:00.000Z", "2026-10-17T09:15:00.000Z", 11, current = false, revoked = false),
Device(oldPhone, jacob, "Pixel 7", "2025-04-12T08:00:00.000Z", "2026-09-30T18:00:00.000Z", 9, current = false, revoked = true),
)
private val pendingDeviceInvite = Invite(InviteId("01920000-0000-7000-8000-0000000000aa"), InviteKind.DEVICE, anna.id, null, null, "2026-10-16T18:30:00.000Z", "2026-10-19T18:30:00.000Z")
val update = UpdateInfo(12, "1.2", 8_400_000)
/** S-13 loaded (admin), S-15, S-19, S-20, S-21 after the partner joined, S-22: everything synced 3 minutes ago. */
val loaded: FakeScenario = FakeScenarios.empty.copy(
session = SessionState.Paired(jacob, anna, jacobPhone),
accounts = listOf(vardag, huset, japan),
categories = categories,
expenses = listOf(pantExpense),
templates = listOf(hyra, bredband, netflix, spotify),
devices = devices,
invites = listOf(pendingDeviceInvite),
lastSuccessAt = "2026-10-17T11:59:00.000Z",
about = AboutState("1.1", 11, "https://icehack.asuscomm.com:8443", "1.1.0"),
today = "2026-10-17",
now = "2026-10-17T12:02:00.000Z",
defaultAccountId = huset.id,
lastCategoryFor = mapOf(huset.id to housing.id),
)
/** Anna's phone: she is the member, Jacob the admin (S-13 member, S-20 member). */
val member: FakeScenario = loaded.copy(
me = anna,
partner = jacob,
deviceId = annaPhone,
session = SessionState.Paired(anna, jacob, annaPhone),
devices = devices.map { it.copy(current = it.id == annaPhone) },
invites = emptyList(),
)
/** Before the partner joins: one user, a pending partner invite, no templates (AC-5.1, AC-2.1). */
val partnerNotJoined: FakeScenario = loaded.copy(
partner = null,
session = SessionState.Paired(jacob, null, jacobPhone),
templates = emptyList(),
expenses = emptyList(),
devices = devices.filter { it.user.id == jacob.id },
invites = listOf(Invite(InviteId("01920000-0000-7000-8000-0000000000ab"), InviteKind.PARTNER, null, null, null, "2026-10-16T18:30:00.000Z", "2026-10-19T18:30:00.000Z")),
)
/** Offline with two changes waiting to sync (§9.9). */
val offline: FakeScenario = loaded.copy(online = false, expenses = listOf(pantExpense, pendingExpense, pendingExpense.copy(id = ExpenseId("01920000-0000-7000-8000-0000000000f3"), createdAt = "2026-10-17T11:40:00.000Z"))).withServerVersion(null)
/** Jacob with a profile picture (S-14). */
val withPicture: FakeScenario = loaded.copy(me = jacob.copy(pictureVersion = 3))
/** No templates (S-19 empty), no pending invites (S-21 empty). */
val empty: FakeScenario = loaded.copy(templates = emptyList(), invites = emptyList())
/** An update is waiting (S-13 "Uppdatering finns", S-22 banner). */
val updateAvailable: FakeScenario = loaded.copy(update = UpdateState.Available(update))
/** The update is downloading (S-22). */
val updateDownloading: FakeScenario = loaded.copy(update = UpdateState.Downloading(update, 0.64f))
private val longMe = jacob.copy(displayName = "Jacob Hagstedt-Lindqvist")
private val longPartner = anna.copy(displayName = "Anna-Karin Lindqvist-Östergren")
private val longAccount = huset.copy(name = "Sommarstugan på Gotland med brygga")
/** Long Swedish names and 7-digit amounts (SPEC §13.3 long content). */
val long: FakeScenario = loaded.copy(
me = longMe,
partner = longPartner,
session = SessionState.Paired(longMe, longPartner, jacobPhone),
accounts = listOf(vardag, longAccount, japan),
categories = categories.map {
when (it.id) {
groceries.id -> it.copy(customName = "Matvaror, hushållsartiklar och förbrukning")
hund.id -> it.copy(customName = "Hundens veterinärbesök och försäkring")
else -> it
}
},
templates = listOf(
hyra.copy(draft = hyra.draft.copy(accountId = longAccount.id, amountOre = 123_456_789, description = "Månadshyra för sommarstugan inklusive el, vatten och sophämtning"), dayOfMonth = 31),
bredband,
spotify.copy(draft = spotify.draft.copy(description = "Spotify Family och Storytel Familj, delat abonnemang")),
),
devices = devices.map {
when (it.user.id) {
jacob.id -> it.copy(user = longMe, name = if (it.current) "Samsung Galaxy S26 Ultra Enterprise Edition" else it.name)
else -> it.copy(user = longPartner)
}
},
)
private fun FakeScenario.withServerVersion(version: String?): FakeScenario = copy(about = about.copy(serverVersion = version))
/** Every scenario of this file by name, for previews and tests. */
val all: Map<String, FakeScenario> = mapOf(
"loaded" to loaded,
"member" to member,
"partnerNotJoined" to partnerNotJoined,
"offline" to offline,
"withPicture" to withPicture,
"empty" to empty,
"updateAvailable" to updateAvailable,
"updateDownloading" to updateDownloading,
"long" to long,
)
/** Roles stay as the household has them. */
init {
check(jacob.role == Role.ADMIN && anna.role == Role.MEMBER)
}
}