From c4ab35f66e9a309e761f30d1e059be06bdb3fd1c Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 11:04:29 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../settings/ScriptedSettingsRepositories.kt | 102 ++++ .../kvitt/fakes/settings/SettingsScenarios.kt | 210 ++++++++ android/feature/settings/build.gradle.kts | 18 + .../kvitt/feature/settings/SettingsEntry.kt | 14 - .../feature/settings/about/AboutScreen.kt | 152 ++++++ .../feature/settings/about/AboutViewModel.kt | 68 +++ .../categories/CategoryEditorScreen.kt | 206 ++++++++ .../categories/CategoryEditorViewModel.kt | 227 +++++++++ .../settings/categories/CategoryListScreen.kt | 285 +++++++++++ .../categories/CategoryListViewModel.kt | 132 +++++ .../kvitt/feature/settings/common/Messages.kt | 118 +++++ .../kvitt/feature/settings/common/Ui.kt | 58 +++ .../feature/settings/devices/DevicesScreen.kt | 191 ++++++++ .../settings/devices/DevicesViewModel.kt | 107 +++++ .../feature/settings/hub/SettingsHubScreen.kt | 170 +++++++ .../settings/hub/SettingsHubViewModel.kt | 101 ++++ .../feature/settings/invites/InvitesScreen.kt | 283 +++++++++++ .../settings/invites/InvitesViewModel.kt | 145 ++++++ .../feature/settings/profile/ProfileScreen.kt | 173 +++++++ .../settings/profile/ProfileViewModel.kt | 153 ++++++ .../feature/settings/recurring/Periods.kt | 34 ++ .../recurring/RecurringEditorScreen.kt | 451 ++++++++++++++++++ .../recurring/RecurringEditorViewModel.kt | 279 +++++++++++ .../settings/recurring/RecurringForm.kt | 204 ++++++++ .../settings/recurring/RecurringListScreen.kt | 174 +++++++ .../recurring/RecurringListViewModel.kt | 53 ++ .../settings/recurring/RecurringWords.kt | 12 + .../src/main/res/values-sv/strings.xml | 194 ++++++++ .../settings/src/main/res/values/strings.xml | 194 ++++++++ .../settings/logic/MainDispatcherRule.kt | 17 + .../feature/settings/logic/PureRulesTest.kt | 127 +++++ .../feature/settings/logic/StateHolderTest.kt | 259 ++++++++++ .../screenshot/CategoriesScreenshotTest.kt | 110 +++++ .../DevicesInvitesAboutScreenshotTest.kt | 123 +++++ .../settings/screenshot/HubScreenshotTest.kt | 55 +++ .../screenshot/ProfileScreenshotTest.kt | 70 +++ .../screenshot/RecurringScreenshotTest.kt | 144 ++++++ .../settings/screenshot/ScreenSnapshots.kt | 115 +++++ .../feature/settings/screenshot/Shots.kt | 11 + 39 files changed, 5525 insertions(+), 14 deletions(-) create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt create mode 100644 android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt delete mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Ui.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/Periods.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListViewModel.kt create mode 100644 android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringWords.kt create mode 100644 android/feature/settings/src/main/res/values-sv/strings.xml create mode 100644 android/feature/settings/src/main/res/values/strings.xml create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/PureRulesTest.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ScreenSnapshots.kt create mode 100644 android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt new file mode 100644 index 0000000..b856e0a --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/ScriptedSettingsRepositories.kt @@ -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 run(call: suspend () -> OnlineResult): OnlineResult { + 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) = 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 = 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> = script.run { fakes.devices.list() } + override suspend fun revoke(id: DeviceId): OnlineResult = script.run { fakes.devices.revoke(id) } + } + + val invites: InviteRepository = object : InviteRepository { + override suspend fun list(): OnlineResult> = script.run { fakes.invites.list() } + override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult = 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 = 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() + } +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt new file mode 100644 index 0000000..bb14b76 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/settings/SettingsScenarios.kt @@ -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 = FakeScenarios.defaultCategories + hund + pant + + /** The default category Groceries renamed "Matvaror" (S-15 editor, AC-7.4). */ + val renamedCategories: List = 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? = null, exact: Map? = 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 = 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) + } +} diff --git a/android/feature/settings/build.gradle.kts b/android/feature/settings/build.gradle.kts index 8757b07..7bf864e 100644 --- a/android/feature/settings/build.gradle.kts +++ b/android/feature/settings/build.gradle.kts @@ -1,4 +1,22 @@ // :feature:settings — Settings hub, profile, categories, recurring, devices, invites, about (S-13 to S-15, S-19 to S-22; M7f). Convention: android/README.md. plugins { id("kvitt.android.feature") + alias(libs.plugins.roborazzi) +} + +roborazzi { + outputDir.set(file("src/test/screenshots")) +} + +dependencies { + // The Android photo picker (AC-4.3): ActivityResultContracts.PickVisualMedia through rememberLauncherForActivityResult. + implementation(libs.androidx.activity.compose) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.compose) + testImplementation(libs.roborazzi.junit.rule) } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt deleted file mode 100644 index 875008e..0000000 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/SettingsEntry.kt +++ /dev/null @@ -1,14 +0,0 @@ -package se.hagstedt.kvitt.feature.settings - -import androidx.compose.foundation.layout.Box -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier - -/** - * Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen - * that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md). - */ -@Composable -fun SettingsEntry(modifier: Modifier = Modifier) { - Box(modifier) -} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt new file mode 100644 index 0000000..265ea9d --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt @@ -0,0 +1,152 @@ +package se.hagstedt.kvitt.feature.settings.about + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.ButtonSize +import se.hagstedt.kvitt.design.component.FactRow +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.NeedsConnectionHint +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SegmentedItemScope +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.UpdateBanner +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.core.design.R as DesignR + +/** + * S-22 About. [updateCheckAvailable] false hides "Sök efter uppdatering" (debug builds never check, AC-16.5; `:app` + * passes its build type). [onOpenLicences] opens the open-source licences screen (`:app`). + */ +@Composable +fun AboutScreen( + onBack: () -> Unit, + onOpenLicences: () -> Unit, + updateCheckAvailable: Boolean, + modifier: Modifier = Modifier, + viewModel: AboutViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + AboutContent(state, AboutActions(onBack, onOpenLicences, viewModel::check, viewModel::install, viewModel::later, viewModel::onMessageShown), updateCheckAvailable, modifier) +} + +class AboutActions( + val onBack: () -> Unit = {}, + val onOpenLicences: () -> Unit = {}, + val onCheck: () -> Unit = {}, + val onInstall: () -> Unit = {}, + val onLater: () -> Unit = {}, + val onMessageShown: () -> Unit = {}, +) + +/** Stateless S-22. */ +@Composable +fun AboutContent(state: AboutScreenState?, actions: AboutActions, updateCheckAvailable: Boolean, modifier: Modifier = Modifier) { + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { KvittTopAppBar(stringResource(R.string.settings_about_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) }, + message = state?.message, + onMessageShown = actions.onMessageShown, + ) { padding -> + if (state == null) return@SettingsScaffold + val about = state.about + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + Column(Modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s4), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + Box(Modifier.size(KvittTheme.spacing.s12 * 2).background(MaterialTheme.colorScheme.primary, KvittTheme.shapes.avatarLarge).clearAndSetSemantics { }, contentAlignment = Alignment.Center) { + KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittTheme.spacing.s12, tint = MaterialTheme.colorScheme.onPrimary) + } + Text(stringResource(R.string.settings_about_version, about.versionName, about.versionCode), style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center) + Text(stringResource(DesignR.string.common_tagline), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (!state.bannerHidden && state.update !is UpdateState.None) { + UpdateBanner(state.update, state.online, actions.onInstall, actions.onLater, actions.onInstall) + } + SegmentedGroup(3) { item -> + when (item.index) { + 0 -> FactRow(item, KvittIcon.Dns, stringResource(R.string.settings_about_server), about.serverUrl) + 1 -> FactRow(item, KvittIcon.Memory, stringResource(R.string.settings_about_server_version), about.serverVersion ?: stringResource(R.string.settings_about_server_version_unknown)) + else -> LicencesRow(item, actions.onOpenLicences) + } + } + if (updateCheckAvailable) { + Column(Modifier.fillMaxWidth().padding(top = KvittTheme.spacing.s2), horizontalAlignment = Alignment.CenterHorizontally) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + KvittButton( + stringResource(R.string.settings_about_check_update), + actions.onCheck, + kind = ButtonKind.Tonal, + size = ButtonSize.Medium, + icon = KvittIcon.SystemUpdate, + enabled = state.online && !state.checking, + ) + if (state.checking) CircularProgressIndicator(Modifier.size(KvittDimens.icon), strokeWidth = KvittDimens.placeholderBorder) + } + if (!state.online) NeedsConnectionHint() + } + } + } + } +} + +/** "Licenser för öppen källkod" as a fact row with a chevron (design-spec S-22). */ +@Composable +private fun LicencesRow(item: SegmentedItemScope, onClick: () -> Unit) { + val label = stringResource(R.string.settings_about_licences) + Row( + Modifier + .fillMaxWidth() + .clip(item.shape) + .background(item.fill) + .clickable(onClick = onClick) + .clearAndSetSemantics { contentDescription = label; role = Role.Button } + .heightIn(min = KvittTheme.spacing.rowMin) + .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + KvittIconImage(KvittIcon.Description, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge) + KvittIconImage(KvittIcon.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt new file mode 100644 index 0000000..ebd422b --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutViewModel.kt @@ -0,0 +1,68 @@ +package se.hagstedt.kvitt.feature.settings.about + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.UiMessage +import se.hagstedt.kvitt.model.AboutRepository +import se.hagstedt.kvitt.model.AboutState +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.UpdateRepository +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.core.design.R as DesignR +import javax.inject.Inject + +/** S-22 (design-spec §4 S-22): version, server, server version (null when `/health` failed), the update banner. */ +data class AboutScreenState(val about: AboutState, val update: UpdateState, val online: Boolean, val checking: Boolean, val bannerHidden: Boolean, val message: UiMessage?) + +@HiltViewModel +class AboutViewModel @Inject constructor( + about: AboutRepository, + private val update: UpdateRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + private val local = MutableStateFlow(Local()) + + val state: StateFlow = combine(about.about(), update.state, connectivity.online, local) { a, u, online, l -> + AboutScreenState(a, u, online, l.checking, l.bannerHidden, l.message) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + /** + * "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner, or "Du har den senaste + * versionen". `check()` reports no failure (§14.2.4), so a check that finds nothing reads as up to date. + */ + fun check() { + val s = state.value ?: return + if (s.checking) return + if (!s.online) { + local.update { it.copy(message = UiMessage(DesignR.string.common_could_not_reach_server)) } + return + } + local.update { it.copy(checking = true, bannerHidden = false) } + viewModelScope.launch { + update.check() + val found = update.state.first() + local.update { it.copy(checking = false, message = if (found is UpdateState.None) UiMessage(R.string.settings_about_up_to_date) else it.message) } + } + } + + fun install() { + viewModelScope.launch { update.install() } + } + + /** "Senare" hides the banner for this visit. */ + fun later() = local.update { it.copy(bannerHidden = true) } + + fun onMessageShown() = local.update { it.copy(message = null) } + + private data class Local(val checking: Boolean = false, val bannerHidden: Boolean = false, val message: UiMessage? = null) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt new file mode 100644 index 0000000..6c6e66d --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorScreen.kt @@ -0,0 +1,206 @@ +package se.hagstedt.kvitt.feature.settings.categories + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.CategoryIconPicker +import se.hagstedt.kvitt.design.component.CategoryTokenTile +import se.hagstedt.kvitt.design.component.ColorSwatchPicker +import se.hagstedt.kvitt.design.component.ConfirmDialog +import se.hagstedt.kvitt.design.component.KvittBadge +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittTextField +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.NeedsConnectionHint +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.defaultCategoryName +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.HintText +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.feature.settings.common.SaveAction +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.core.design.R as DesignR + +/** + * S-15 category editor. [onClose] after save, delete or the close icon; [onArchived] after "Arkivera" / "Återställ från + * arkiv" (`:app` closes the editor and hands the result to [CategoryListScreen] for its snackbar). + */ +@Composable +fun CategoryEditorScreen( + onClose: () -> Unit, + onArchived: (ArchiveResult) -> Unit, + modifier: Modifier = Modifier, + viewModel: CategoryEditorViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(state?.outcome) { + when (val outcome = state?.outcome) { + EditorOutcome.Saved, EditorOutcome.Deleted -> onClose() + is EditorOutcome.Archived -> onArchived(outcome.result) + null -> Unit + } + } + CategoryEditorContent( + state, + CategoryEditorActions( + onClose = onClose, + onName = viewModel::onName, + onIcon = viewModel::onIcon, + onColor = viewModel::onColor, + onResetName = viewModel::resetName, + onSave = viewModel::save, + onToggleArchived = viewModel::toggleArchived, + onAskDelete = viewModel::askDelete, + onDismissDelete = viewModel::dismissDelete, + onDelete = viewModel::delete, + onMessageShown = viewModel::onMessageShown, + ), + modifier, + ) +} + +class CategoryEditorActions( + val onClose: () -> Unit = {}, + val onName: (String) -> Unit = {}, + val onIcon: (String) -> Unit = {}, + val onColor: (ColorToken) -> Unit = {}, + val onResetName: () -> Unit = {}, + val onSave: () -> Unit = {}, + val onToggleArchived: (String) -> Unit = {}, + val onAskDelete: () -> Unit = {}, + val onDismissDelete: () -> Unit = {}, + val onDelete: () -> Unit = {}, + val onMessageShown: () -> Unit = {}, +) + +/** Stateless S-15 editor; [inlineDialog] renders the delete confirmation in the layout (screenshots, no window). */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun CategoryEditorContent(state: CategoryEditorState?, actions: CategoryEditorActions, modifier: Modifier = Modifier) { + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { + val title = if (state?.isNew != false) R.string.settings_category_editor_new else R.string.settings_category_editor_edit + KvittTopAppBar(stringResource(title), navigation = TopBarNavigation.Close, onNavigation = actions.onClose) { + SaveAction(enabled = state?.canSave == true, onClick = actions.onSave) + } + }, + message = state?.message, + onMessageShown = actions.onMessageShown, + ) { padding -> + if (state == null) return@SettingsScaffold + val defaultName = state.defaultKey?.let { stringResource(defaultCategoryName(it)) } + val shownName = state.name.trim().ifEmpty { defaultName ?: "" } + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + if (!state.online) NeedsConnectionHint() + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s4)) { + CategoryTokenTile(state.icon, state.color, KvittDimens.tilePreview, archived = state.archived) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + KvittTextField( + value = state.name, + onValueChange = actions.onName, + label = stringResource(DesignR.string.common_name), + error = state.nameError?.let { nameErrorText(it) }, + placeholder = defaultName, + onIme = actions.onSave, + enabled = state.online && !state.busy, + ) + if (defaultName != null) HintText(stringResource(R.string.settings_category_editor_default_name, defaultName), Modifier.padding(start = KvittTheme.spacing.s4)) + if (state.canResetName) { + KvittButton(stringResource(R.string.settings_category_editor_reset_name), actions.onResetName, kind = ButtonKind.Text, icon = KvittIcon.RestartAlt, enabled = state.online) + } + if (state.archived) KvittBadge(stringResource(R.string.settings_category_archived_badge), Modifier.padding(start = KvittTheme.spacing.s4), icon = KvittIcon.Inventory2) + } + } + Text(stringResource(R.string.settings_category_editor_icon), style = MaterialTheme.typography.titleSmall) + CategoryIconPicker(state.icon, state.color, { if (state.online) actions.onIcon(it) }) + Text(stringResource(R.string.settings_category_editor_color), style = MaterialTheme.typography.titleSmall) + ColorSwatchPicker(state.color, { if (state.online) actions.onColor(it) }) + if (!state.isNew) { + FlowRow( + Modifier.fillMaxWidth().padding(top = KvittTheme.spacing.s2), + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + KvittButton( + stringResource(if (state.archived) R.string.settings_category_editor_unarchive else R.string.settings_category_editor_archive), + { actions.onToggleArchived(shownName) }, + kind = ButtonKind.Outlined, + icon = if (state.archived) KvittIcon.Unarchive else KvittIcon.Inventory2, + enabled = state.online && !state.busy, + ) + DeleteControl(state, actions) + } + } + } + if (state.confirmDelete) { + ConfirmDialog( + icon = KvittIcon.Delete, + title = stringResource(R.string.settings_category_delete_title, shownName), + body = stringResource(R.string.settings_category_delete_body), + confirm = stringResource(DesignR.string.common_delete), + dismiss = stringResource(DesignR.string.common_cancel), + onConfirm = actions.onDelete, + onDismiss = actions.onDismissDelete, + destructive = true, + ) + } + } +} + +/** D-059: the default lock line, the disabled button with its reason, or the enabled danger button. */ +@Composable +private fun DeleteControl(state: CategoryEditorState, actions: CategoryEditorActions) { + when (state.deleteBlock) { + DeleteBlock.Default -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + KvittIconImage(KvittIcon.Lock, null, size = KvittDimens.iconHint, tint = MaterialTheme.colorScheme.onSurfaceVariant) + HintText(stringResource(R.string.settings_category_delete_blocked_default_short)) + } + DeleteBlock.InUse, DeleteBlock.InUseArchived -> Column { + KvittButton(stringResource(R.string.settings_category_editor_delete), {}, kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = false) + HintText( + stringResource(if (state.deleteBlock == DeleteBlock.InUseArchived) R.string.settings_category_delete_blocked_in_use_archived else R.string.settings_category_delete_blocked_in_use), + Modifier.padding(horizontal = KvittTheme.spacing.s3), + ) + } + null -> KvittButton(stringResource(R.string.settings_category_editor_delete), actions.onAskDelete, kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = state.online && !state.busy) + } +} + +@Composable +private fun nameErrorText(error: CategoryNameError): String = when (error) { + CategoryNameError.Required -> stringResource(DesignR.string.common_name_required) + CategoryNameError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, CATEGORY_NAME_MAX, CATEGORY_NAME_MAX) + CategoryNameError.Refused -> stringResource(DesignR.string.error_validation) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorViewModel.kt new file mode 100644 index 0000000..eee88f6 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryEditorViewModel.kt @@ -0,0 +1,227 @@ +package se.hagstedt.kvitt.feature.settings.categories + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.feature.settings.common.UiMessage +import se.hagstedt.kvitt.feature.settings.common.failureCode +import se.hagstedt.kvitt.feature.settings.common.failureMessage +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryIcons +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.CategoryRepository +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.OnlineResult +import javax.inject.Inject + +/** Why "Ta bort kategori" is not offered (D-059, design-spec S-15). */ +enum class DeleteBlock { Default, InUse, InUseArchived } + +/** The delete affordance before any tap: from `defaultKey` and `CategoryRepository.deletable(id)` (D-059). */ +fun deleteBlock(defaultKey: DefaultCategoryKey?, deletable: Boolean, archived: Boolean): DeleteBlock? = when { + defaultKey != null -> DeleteBlock.Default + deletable -> null + archived -> DeleteBlock.InUseArchived + else -> DeleteBlock.InUse +} + +/** A refused delete (`category_in_use`, `category_is_default`) shows the same reason inline. */ +fun deleteBlockOf(code: String, archived: Boolean): DeleteBlock? = when (code) { + "category_is_default" -> DeleteBlock.Default + "category_in_use" -> if (archived) DeleteBlock.InUseArchived else DeleteBlock.InUse + else -> null +} + +/** §9.6: a category name has 1 to 40 code points (a default category may be empty: its default name). */ +const val CATEGORY_NAME_MAX = 40 + +/** Field errors of the name. */ +enum class CategoryNameError { Required, TooLong, Refused } + +fun categoryNameError(name: String, isDefault: Boolean): CategoryNameError? { + val trimmed = name.trim() + val length = trimmed.codePointCount(0, trimmed.length) + return when { + length == 0 && !isDefault -> CategoryNameError.Required + length > CATEGORY_NAME_MAX -> CategoryNameError.TooLong + else -> null + } +} + +/** + * S-15 editor (design-spec §4 S-15). [original] null for "Ny kategori". [name] is the field: for a default category an + * empty field means the default name (saved as `null`, AC-7.4). + */ +data class CategoryEditorState( + val original: Category?, + val name: String, + val icon: String, + val color: ColorToken, + val nameError: CategoryNameError?, + val deleteBlock: DeleteBlock?, + val online: Boolean, + val busy: Boolean, + val confirmDelete: Boolean, + val message: UiMessage?, + val outcome: EditorOutcome?, +) { + val isNew: Boolean get() = original == null + val defaultKey: DefaultCategoryKey? get() = original?.defaultKey + val archived: Boolean get() = original?.archived == true + + /** "Återställ namn" shows for a default category while the field holds a custom name. */ + val canResetName: Boolean get() = defaultKey != null && name.isNotBlank() + + val changed: Boolean get() = original == null || name.trim() != (original.customName ?: "") || icon != original.icon || color != original.color + + val canSave: Boolean get() = online && !busy && changed && nameError == null +} + +/** How the editor ends: saved or deleted (back), or archived / unarchived (back with a list snackbar). */ +sealed interface EditorOutcome { + data object Saved : EditorOutcome + data object Deleted : EditorOutcome + data class Archived(val result: ArchiveResult) : EditorOutcome +} + +@HiltViewModel +class CategoryEditorViewModel @Inject constructor( + savedState: SavedStateHandle, + private val categories: CategoryRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + /** Navigation argument: the category to edit, absent for a new one. */ + private val id: CategoryId? = savedState.get(ARG_CATEGORY_ID)?.let { CategoryId(it) } + + private val form = MutableStateFlow(Form()) + + val state: StateFlow = combine( + categories.all(), + id?.let { categories.deletable(it) } ?: flowOf(true), + connectivity.online, + form, + ) { all, deletable, online, f -> + val original = id?.let { i -> all.firstOrNull { it.id == i } } + if (id != null && original == null) return@combine null + val name = f.name ?: original?.customName ?: "" + val isDefault = original?.defaultKey != null + CategoryEditorState( + original = original, + name = name, + icon = f.icon ?: original?.icon ?: CategoryIcons.ALL.first(), + color = f.color ?: original?.color ?: ColorToken.GREEN, + nameError = f.serverNameError ?: if (f.name != null || f.triedSave) categoryNameError(name, isDefault) else null, + deleteBlock = f.refusedDelete ?: original?.let { deleteBlock(it.defaultKey, deletable, it.archived) }, + online = online, + busy = f.busy, + confirmDelete = f.confirmDelete, + message = f.message, + outcome = f.outcome, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun onName(name: String) = form.update { it.copy(name = name, serverNameError = null) } + + fun onIcon(icon: String) = form.update { it.copy(icon = icon) } + + fun onColor(color: ColorToken) = form.update { it.copy(color = color) } + + /** "Återställ namn": the field empties, the default name returns; saved with "Spara" (`name = null`). */ + fun resetName() = form.update { it.copy(name = "", serverNameError = null) } + + /** "Spara": `update` or `create`; success → back. */ + fun save() { + form.update { it.copy(triedSave = true) } + val s = state.value ?: return + if (!s.canSave) return + form.update { it.copy(busy = true) } + viewModelScope.launch { + val name = s.name.trim() + val result: OnlineResult<*> = when (val original = s.original) { + null -> categories.create(name, s.icon, s.color) + else -> categories.update(original.id, if (original.defaultKey != null && name.isEmpty()) null else name, s.icon, s.color) + } + form.update { it.copy(busy = false) } + when { + result is OnlineResult.Ok -> form.update { it.copy(outcome = EditorOutcome.Saved) } + result is OnlineResult.Failed && result.field == "name" -> form.update { it.copy(serverNameError = CategoryNameError.Refused) } + else -> fail(result) + } + } + } + + /** "Arkivera" / "Återställ från arkiv": immediate (online), back to the list with a snackbar. */ + fun toggleArchived(displayName: String) { + val s = state.value ?: return + val original = s.original ?: return + if (!s.online || s.busy) return + form.update { it.copy(busy = true) } + viewModelScope.launch { + val result = categories.setArchived(original.id, !original.archived) + form.update { it.copy(busy = false) } + if (result is OnlineResult.Ok) { + form.update { it.copy(outcome = EditorOutcome.Archived(ArchiveResult(displayName, !original.archived))) } + } else { + fail(result) + } + } + } + + fun askDelete() = form.update { it.copy(confirmDelete = state.value?.let { s -> s.online && s.deleteBlock == null } == true) } + + fun dismissDelete() = form.update { it.copy(confirmDelete = false) } + + /** Confirmed delete; `category_in_use` / `category_is_default` show the reason inline (D-059). */ + fun delete() { + val s = state.value ?: return + val original = s.original ?: return + form.update { it.copy(confirmDelete = false, busy = true) } + viewModelScope.launch { + val result = categories.delete(original.id) + form.update { it.copy(busy = false) } + val code = result.failureCode() + val block = code?.let { deleteBlockOf(it, original.archived) } + when { + result is OnlineResult.Ok -> form.update { it.copy(outcome = EditorOutcome.Deleted) } + block != null -> form.update { it.copy(refusedDelete = block) } + else -> fail(result) + } + } + } + + fun onMessageShown() = form.update { it.copy(message = null) } + + private fun fail(result: OnlineResult<*>) { + val code = result.failureCode() ?: return + form.update { it.copy(message = failureMessage(code, canRetry = false)) } + } + + private data class Form( + val name: String? = null, + val icon: String? = null, + val color: ColorToken? = null, + val serverNameError: CategoryNameError? = null, + val triedSave: Boolean = false, + val refusedDelete: DeleteBlock? = null, + val busy: Boolean = false, + val confirmDelete: Boolean = false, + val message: UiMessage? = null, + val outcome: EditorOutcome? = null, + ) + + companion object { + /** The `SavedStateHandle` key of the category id (a string; absent for a new category). */ + const val ARG_CATEGORY_ID = "categoryId" + } +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt new file mode 100644 index 0000000..a7cdc81 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListScreen.kt @@ -0,0 +1,285 @@ +package se.hagstedt.kvitt.feature.settings.categories + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.zIndex +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittElevation +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.CategoryTile +import se.hagstedt.kvitt.design.component.CollapsibleRow +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.NeedsConnectionHint +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SegmentedItemScope +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.categoryName +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId + +/** + * S-15 Categories list. [archiveResult] is the editor's archive or unarchive, handed back by `:app` when the editor + * closes; it shows as a snackbar once and [onArchiveResultShown] clears it. + */ +@Composable +fun CategoryListScreen( + onBack: () -> Unit, + onOpenCategory: (CategoryId) -> Unit, + onNewCategory: () -> Unit, + modifier: Modifier = Modifier, + archiveResult: ArchiveResult? = null, + onArchiveResultShown: () -> Unit = {}, + viewModel: CategoryListViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(archiveResult) { + if (archiveResult != null) { + viewModel.onArchiveResult(archiveResult) + onArchiveResultShown() + } + } + CategoryListContent( + state, + CategoryListActions( + onBack = onBack, + onOpen = onOpenCategory, + onNew = onNewCategory, + onToggleArchived = viewModel::toggleArchived, + onStartDrag = viewModel::startDrag, + onDragTo = viewModel::dragTo, + onEndDrag = viewModel::endDrag, + onMove = viewModel::move, + onMessageShown = viewModel::onMessageShown, + ), + modifier, + ) +} + +class CategoryListActions( + val onBack: () -> Unit = {}, + val onOpen: (CategoryId) -> Unit = {}, + val onNew: () -> Unit = {}, + val onToggleArchived: () -> Unit = {}, + val onStartDrag: (CategoryId) -> Unit = {}, + val onDragTo: (Int) -> Unit = {}, + val onEndDrag: () -> Unit = {}, + val onMove: (CategoryId, Int) -> Unit = { _, _ -> }, + val onMessageShown: () -> Unit = {}, +) + +/** Stateless S-15 list. */ +@Composable +fun CategoryListContent(state: CategoryListState?, actions: CategoryListActions, modifier: Modifier = Modifier) { + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { + KvittTopAppBar(stringResource(R.string.settings_categories_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) { + IconButton(actions.onNew, enabled = state?.online == true) { + KvittIconImage(KvittIcon.Add, stringResource(R.string.settings_categories_new)) + } + } + }, + message = state?.message, + onMessageShown = actions.onMessageShown, + ) { padding -> + if (state == null) return@SettingsScaffold + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), + ) { + if (!state.online) NeedsConnectionHint(text = stringResource(R.string.settings_categories_reorder_offline)) + ActiveGroup(state, actions) + CollapsibleRow( + stringResource(R.string.settings_categories_archived, state.archived.size), + expanded = state.archivedExpanded, + expandedState = stringResource(R.string.settings_expanded), + collapsedState = stringResource(R.string.settings_collapsed), + onToggle = actions.onToggleArchived, + ) + if (state.archivedExpanded && state.archived.isNotEmpty()) { + SegmentedGroup(state.archived.size) { item -> + CategoryRow(item, state.archived[item.index], state.online, handle = null, onClick = { actions.onOpen(state.archived[item.index].id) }) + } + } + Spacer(Modifier.heightIn(min = KvittTheme.spacing.s6)) + } + } +} + +/** The active group with drag-to-reorder: long-press a row or drag its handle; drop saves (design-spec S-15). */ +@Composable +private fun ActiveGroup(state: CategoryListState, actions: CategoryListActions) { + var rowHeight by remember { mutableIntStateOf(1) } + var offset by remember { mutableFloatStateOf(0f) } + val active by rememberUpdatedState(state.active) + val dragging by rememberUpdatedState(state.dragging) + val moveUp = stringResource(R.string.settings_categories_move_up) + val moveDown = stringResource(R.string.settings_categories_move_down) + fun dragBy(delta: Float) { + val id = dragging ?: return + offset += delta + val step = rowHeight.toFloat() + val index = active.indexOfFirst { it.id == id } + if (offset > step / 2 && index < active.lastIndex) { + actions.onDragTo(index + 1) + offset -= step + } else if (offset < -step / 2 && index > 0) { + actions.onDragTo(index - 1) + offset += step + } + } + fun end() { + offset = 0f + actions.onEndDrag() + } + SegmentedGroup(state.active.size) { item -> + val category = state.active[item.index] + val isDragged = category.id == state.dragging + val drag = Modifier.pointerInput(category.id, state.online) { + if (state.online) { + detectDragGestures(onDragStart = { actions.onStartDrag(category.id) }, onDragEnd = ::end, onDragCancel = ::end) { change, amount -> + change.consume() + dragBy(amount.y) + } + } + } + val longPress = Modifier.pointerInput(category.id, state.online) { + if (state.online) { + detectDragGesturesAfterLongPress(onDragStart = { actions.onStartDrag(category.id) }, onDragEnd = ::end, onDragCancel = ::end) { change, amount -> + change.consume() + dragBy(amount.y) + } + } + } + val a11y = if (state.online) { + Modifier.semantics { + customActions = listOf( + CustomAccessibilityAction(moveUp) { actions.onMove(category.id, -1); true }, + CustomAccessibilityAction(moveDown) { actions.onMove(category.id, 1); true }, + ) + } + } else { + Modifier + } + val lifted = if (isDragged) { + Modifier + .zIndex(1f) + .graphicsLayer { translationY = offset } + .shadow(KvittElevation.overlay, item.shape) + } else { + Modifier + } + CategoryRow( + item, + category, + state.online, + handle = if (state.online) DragHandle(drag) else null, + onClick = { actions.onOpen(category.id) }, + modifier = lifted.onSizeChanged { rowHeight = it.height.coerceAtLeast(1) }.then(longPress).then(a11y), + lifted = isDragged, + ) + } +} + +/** The drag gesture of a row's handle. */ +private class DragHandle(val gesture: Modifier) + +/** + * One category row: drag handle (32 × 48, active and online only; a 32 dp spacer for archived rows), tile 36, name, + * chevron. Offline: not clickable, no chevron. + */ +@Composable +private fun CategoryRow( + item: SegmentedItemScope, + category: Category, + online: Boolean, + handle: DragHandle?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + lifted: Boolean = false, +) { + val name = categoryName(category) + val handleLabel = stringResource(R.string.settings_categories_drag_handle, name) + val fill = if (lifted) MaterialTheme.colorScheme.surfaceContainerHighest else item.fill + Row( + modifier + .fillMaxWidth() + .clip(item.shape) + .background(fill) + .clickable(enabled = online, role = Role.Button, onClick = onClick) + .heightIn(min = KvittTheme.spacing.rowMin) + .padding(start = KvittTheme.spacing.s2, end = KvittTheme.spacing.s3, top = KvittTheme.spacing.s1, bottom = KvittTheme.spacing.s1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + if (handle != null) { + Box( + Modifier + .size(width = KvittTheme.spacing.s8, height = KvittTheme.spacing.touchTarget) + .then(handle.gesture) + .clearAndSetSemantics { contentDescription = handleLabel; role = Role.Button }, + contentAlignment = Alignment.Center, + ) { + KvittIconImage(KvittIcon.DragIndicator, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + Spacer(Modifier.width(KvittTheme.spacing.s8)) + } + CategoryTile(category, KvittDimens.tileSmall) + Text(name, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (online) KvittIconImage(KvittIcon.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListViewModel.kt new file mode 100644 index 0000000..ffb0729 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/categories/CategoryListViewModel.kt @@ -0,0 +1,132 @@ +package se.hagstedt.kvitt.feature.settings.categories + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.UiMessage +import se.hagstedt.kvitt.feature.settings.common.failureCode +import se.hagstedt.kvitt.feature.settings.common.failureMessage +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.CategoryRepository +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.OnlineResult +import javax.inject.Inject + +/** + * S-15 list (design-spec §4 S-15): active categories in `sort_order` (or in the order being dragged), the archived + * section, online state for the drag handles and "Ny kategori", and the snackbar. + */ +data class CategoryListState( + val active: List, + val archived: List, + val online: Boolean, + val archivedExpanded: Boolean, + val dragging: CategoryId?, + val message: UiMessage?, +) + +/** The editor's archive or unarchive, shown as a snackbar on the list ("Pant arkiverades"). */ +data class ArchiveResult(val name: String, val archived: Boolean) + +/** [ids] with [id] moved to [to] (clamped). */ +fun moveId(ids: List, id: CategoryId, to: Int): List { + val from = ids.indexOf(id) + if (from < 0) return ids + val target = to.coerceIn(0, ids.lastIndex) + if (target == from) return ids + return ids.toMutableList().apply { removeAt(from); add(target, id) } +} + +@HiltViewModel +class CategoryListViewModel @Inject constructor( + private val categories: CategoryRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + private val local = MutableStateFlow(Local()) + + val state: StateFlow = combine(categories.all(), connectivity.online, local) { all, online, l -> + val active = all.filter { !it.archived }.sortedBy { it.sortOrder } + val byId = active.associateBy { it.id } + val ordered = l.order?.mapNotNull { byId[it] }?.takeIf { it.size == active.size } ?: active + CategoryListState( + active = ordered, + archived = all.filter { it.archived }.sortedBy { it.sortOrder }, + online = online, + archivedExpanded = l.archivedExpanded, + dragging = l.dragging, + message = l.message, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun toggleArchived() = local.update { it.copy(archivedExpanded = !it.archivedExpanded) } + + /** Long-press or the handle picks [id] up (online only). */ + fun startDrag(id: CategoryId) { + val current = state.value ?: return + if (!current.online) return + local.update { it.copy(dragging = id, order = current.active.map { c -> c.id }, before = current.active.map { c -> c.id }) } + } + + /** The dragged row passes over position [to]. */ + fun dragTo(to: Int) = local.update { l -> + val id = l.dragging ?: return@update l + l.copy(order = l.order?.let { moveId(it, id, to) }) + } + + /** Drop: `reorder(ids)`; a failure brings the old order back with "Kunde inte nå servern" (design-spec S-15). */ + fun endDrag() { + val l = local.value + val order = l.order ?: return + if (order == l.before) { + local.update { it.copy(dragging = null, order = null, before = null) } + return + } + local.update { it.copy(dragging = null, before = null) } + commit(order) + } + + /** TalkBack "Flytta upp" / "Flytta ned": one step in the list, saved at once. */ + fun move(id: CategoryId, delta: Int) { + val current = state.value ?: return + if (!current.online || local.value.dragging != null) return + val ids = current.active.map { it.id } + val moved = moveId(ids, id, ids.indexOf(id) + delta) + if (moved == ids) return + local.update { it.copy(order = moved) } + commit(moved) + } + + /** The editor archived or unarchived a category (delivered by `:app` on return). */ + fun onArchiveResult(result: ArchiveResult) = local.update { + it.copy(message = UiMessage(if (result.archived) R.string.settings_category_archived_snackbar else R.string.settings_category_unarchived_snackbar, listOf(result.name))) + } + + fun onMessageShown() = local.update { it.copy(message = null) } + + private fun commit(order: List) { + viewModelScope.launch { + val result = categories.reorder(order) + local.update { l -> + val message = if (result is OnlineResult.Ok) l.message else result.failureCode()?.let { failureMessage(it, canRetry = false) } + l.copy(order = null, message = message) + } + } + } + + private data class Local( + val order: List? = null, + val before: List? = null, + val dragging: CategoryId? = null, + val archivedExpanded: Boolean = false, + val message: UiMessage? = null, + ) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt new file mode 100644 index 0000000..3daac26 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Messages.kt @@ -0,0 +1,118 @@ +package se.hagstedt.kvitt.feature.settings.common + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import kotlinx.coroutines.delay +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.KvittScaffold +import se.hagstedt.kvitt.design.component.KvittSnackbar +import se.hagstedt.kvitt.design.component.snackbarMillis +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.core.design.R as DesignR +import se.hagstedt.kvitt.feature.settings.R + +/** + * A snackbar the screen shows once (design-spec §1 conventions): [text] from resources with [args]; [retry] adds + * "Försök igen" (`common_retry`) for a repeatable call that failed with a network error. [id] makes repeats distinct. + */ +data class UiMessage(@param:StringRes val text: Int, val args: List = emptyList(), val retry: Boolean = false, val id: Long = nextId()) { + private companion object { + var counter = 0L + + fun nextId(): Long { + counter += 1 + return counter + } + } +} + +/** The §14.3.2 code of a failed online call; `Offline` reads as "needs connection". */ +fun OnlineResult<*>.failureCode(): String? = when (this) { + is OnlineResult.Ok -> null + OnlineResult.Offline -> OFFLINE + is OnlineResult.Failed -> code +} + +const val OFFLINE = "offline" +const val NETWORK = "network" + +/** + * The words for a refused online call (design-spec §1, `strings.csv` `error_*`): network → "Kunde inte nå servern", + * offline → "Kräver anslutning", known §14.3.2 codes → their text, anything else → the generic validation text. + */ +@StringRes +fun failureText(code: String): Int = when (code) { + NETWORK -> DesignR.string.common_could_not_reach_server + OFFLINE -> DesignR.string.common_needs_connection + "household_full" -> DesignR.string.error_household_full + "household_incomplete" -> DesignR.string.error_household_incomplete + "forbidden" -> DesignR.string.error_forbidden + "not_found" -> DesignR.string.error_not_found + "rate_limited" -> DesignR.string.error_rate_limited + "internal", "unavailable", "not_implemented" -> DesignR.string.error_server + "protocol_unsupported" -> DesignR.string.error_app_too_old + "validation_failed", "invalid_request" -> DesignR.string.error_validation + "image_invalid" -> DesignR.string.profile_picture_invalid + "payload_too_large" -> DesignR.string.profile_picture_too_large + "unsupported_media_type" -> DesignR.string.profile_picture_unsupported + "category_in_use" -> R.string.settings_category_delete_blocked_in_use + "category_is_default" -> R.string.settings_category_delete_blocked_default + "cannot_revoke_current_device" -> R.string.settings_devices_cannot_revoke_current + else -> DesignR.string.error_generic_invalid +} + +/** The snackbar for a failed call: network failures offer "Försök igen" when [canRetry]. */ +fun failureMessage(code: String, canRetry: Boolean = true): UiMessage = UiMessage(failureText(code), retry = canRetry && code == NETWORK) + +/** + * The settings screens' scaffold: [KvittScaffold] plus the one-shot snackbar of [message] (design-spec §3.11: + * `inverse-surface`, 4 s, 10 s with "Försök igen"), drawn at the bottom above the bars. The text is resolved while + * shown, so it follows the app language. [onMessageShown] when it times out or its action is used. + */ +@Composable +fun SettingsScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + message: UiMessage? = null, + onMessageShown: () -> Unit = {}, + onRetry: () -> Unit = {}, + content: @Composable (PaddingValues) -> Unit, +) { + Box(modifier.fillMaxSize()) { + KvittScaffold(Modifier.fillMaxSize(), topBar = topBar, bottomBar = bottomBar, floatingActionButton = floatingActionButton, content = content) + if (message != null) { + LaunchedEffect(message.id) { + delay(snackbarMillis(message.retry)) + onMessageShown() + } + val text = stringResource(message.text, *message.args.toTypedArray()) + KvittSnackbar( + text, + Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .padding(KvittTheme.spacing.s4) + .semantics { liveRegion = LiveRegionMode.Polite }, + action = if (message.retry) stringResource(DesignR.string.common_retry) else null, + onAction = { + onMessageShown() + onRetry() + }, + ) + } + } +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Ui.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Ui.kt new file mode 100644 index 0000000..f053aea --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/common/Ui.kt @@ -0,0 +1,58 @@ +package se.hagstedt.kvitt.feature.settings.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.Banner +import se.hagstedt.kvitt.design.component.BannerKind +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.core.design.R as DesignR + +/** "Spara" in a top app bar: small filled button (design-spec S-14, S-15, S-19). */ +@Composable +fun SaveAction(enabled: Boolean, onClick: () -> Unit) { + Box(Modifier.padding(end = KvittTheme.spacing.s2)) { + KvittButton(stringResource(DesignR.string.common_save), onClick, enabled = enabled) + } +} + +/** + * The offline Warning banner of S-14, S-19 and S-21: `cloud_off` and the screen's sentence, which already starts with + * "Kräver anslutning." (`settings_*_offline_banner`), so it is the banner's only text. + */ +@Composable +fun OfflineBanner(text: String, modifier: Modifier = Modifier) { + Banner(BannerKind.Warning, KvittIcon.CloudOff, text, modifier) +} + +/** A body-small muted hint line (S-19 schedule hints, S-20 footer). */ +@Composable +fun HintText(text: String, modifier: Modifier = Modifier) { + Text(text, modifier, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +/** A centred status block: icon 48 and a title-medium line, optional action (S-20 offline and unreachable). */ +@Composable +fun CentredStatus(icon: KvittIcon, title: String, modifier: Modifier = Modifier, action: String? = null, onAction: () -> Unit = {}) { + Column( + modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s12), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + KvittIconImage(icon, null, size = KvittTheme.spacing.s12, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) + if (action != null) KvittButton(action, onAction, kind = ButtonKind.Tonal, icon = KvittIcon.Refresh) + } +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt new file mode 100644 index 0000000..5f18bf8 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesScreen.kt @@ -0,0 +1,191 @@ +package se.hagstedt.kvitt.feature.settings.devices + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.BadgeKind +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.ConfirmDialog +import se.hagstedt.kvitt.design.component.KvittBadge +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.PersonAvatar +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SegmentedItemScope +import se.hagstedt.kvitt.design.component.Skeleton +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.listDate +import se.hagstedt.kvitt.design.component.personName +import se.hagstedt.kvitt.design.component.relativeTime +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.CentredStatus +import se.hagstedt.kvitt.feature.settings.common.HintText +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.Device +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.core.design.R as DesignR + +/** S-20 Devices. */ +@Composable +fun DevicesScreen(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: DevicesViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + DevicesContent(state, DevicesActions(onBack, viewModel::load, viewModel::askRevoke, viewModel::dismissRevoke, viewModel::revoke, viewModel::onMessageShown), modifier) +} + +class DevicesActions( + val onBack: () -> Unit = {}, + val onRetry: () -> Unit = {}, + val onAskRevoke: (Device) -> Unit = {}, + val onDismissRevoke: () -> Unit = {}, + val onRevoke: () -> Unit = {}, + val onMessageShown: () -> Unit = {}, +) + +/** Stateless S-20. */ +@Composable +fun DevicesContent(state: DevicesState?, actions: DevicesActions, modifier: Modifier = Modifier) { + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { KvittTopAppBar(stringResource(R.string.settings_devices_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) }, + message = state?.message, + onMessageShown = actions.onMessageShown, + ) { padding -> + if (state == null) return@SettingsScaffold + ProvideHousehold(state.me, state.partner) { + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + when (val list = state.list) { + DeviceList.Loading -> { + val loading = stringResource(R.string.settings_devices_loading) + SegmentedGroup(2, Modifier.semantics(mergeDescendants = true) { contentDescription = loading }) { item -> SkeletonRow(item) } + } + is DeviceList.Unavailable -> if (list.unreachable) { + CentredStatus(KvittIcon.CloudOff, stringResource(DesignR.string.common_could_not_reach_server), action = stringResource(DesignR.string.common_retry), onAction = actions.onRetry) + } else { + CentredStatus(KvittIcon.CloudOff, stringResource(R.string.settings_devices_offline)) + } + is DeviceList.Loaded -> { + SegmentedGroup(list.devices.size) { item -> + val device = list.devices[item.index] + DeviceRow(item, device, canRevoke(device, state.me) && state.online && !state.busy) { actions.onAskRevoke(device) } + } + HintText(stringResource(R.string.settings_devices_cannot_revoke_current), Modifier.padding(horizontal = KvittTheme.spacing.s2)) + } + } + } + state.confirm?.let { device -> RevokeDialog(device, state.me, actions) } + } + } +} + +/** A device (AC-3.1): tile, name with "Den här enheten", owner, "Parad … · Senast sedd … · Kvitt …", revoke. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun DeviceRow(item: SegmentedItemScope, device: Device, revocable: Boolean, onRevoke: () -> Unit) { + val colors = KvittTheme.colors + val dot = stringResource(DesignR.string.common_separator_dot) + val meta = listOfNotNull( + stringResource(R.string.settings_devices_paired, listDate(device.pairedAt.take(DATE_LENGTH))), + stringResource(R.string.settings_devices_last_seen, relativeTime(device.lastSeenAt)), + device.appVersionCode?.let { stringResource(R.string.settings_devices_app_version, it.toString()) }, + ).joinToString(dot) + Row( + Modifier + .fillMaxWidth() + .background(item.fill, item.shape) + .heightIn(min = KvittTheme.spacing.ledgerRowMin) + .padding(KvittTheme.spacing.s4), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + Box(Modifier.size(KvittDimens.tileLedger).background(colors.categoryContainer(ColorToken.BLUE), KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) { + KvittIconImage(KvittIcon.Smartphone, null, filled = true, size = KvittDimens.settingsTileIcon, tint = colors.category(ColorToken.BLUE)) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + Column(Modifier.semantics(mergeDescendants = true) {}, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), itemVerticalAlignment = Alignment.CenterVertically) { + Text(device.name, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (device.current) KvittBadge(stringResource(R.string.settings_devices_this_device), kind = BadgeKind.Lantern) + } + Owner(device.user) + Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (revocable) KvittButton(stringResource(R.string.settings_devices_revoke), onRevoke, kind = ButtonKind.DangerText, icon = KvittIcon.Block) + } + } +} + +/** RFC 3339 instants start with the ISO date. */ +private const val DATE_LENGTH = 10 + +@Composable +private fun Owner(user: User) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + PersonAvatar(user, KvittDimens.avatarSmall) + Text(personName(user), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +@Composable +private fun SkeletonRow(item: SegmentedItemScope) { + Row( + Modifier.fillMaxWidth().background(item.fill, item.shape).heightIn(min = KvittTheme.spacing.ledgerRowMin).padding(KvittTheme.spacing.s4), + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + Skeleton(KvittDimens.tileLedger, KvittDimens.tileLedger) + Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + Skeleton(KvittDimens.illustrationWidth, KvittTheme.spacing.s4) + Skeleton(KvittDimens.menuMinWidth, KvittTheme.spacing.s3) + } + } +} + +/** Design-spec S-20 revoke confirmation (destructive, `phonelink_erase`). */ +@Composable +private fun RevokeDialog(device: Device, me: User, actions: DevicesActions) { + ConfirmDialog( + icon = KvittIcon.PhonelinkErase, + title = stringResource(R.string.settings_devices_revoke_title, device.name), + body = if (device.user.id == me.id) stringResource(R.string.settings_devices_revoke_body_own) else stringResource(R.string.settings_devices_revoke_body_partner, device.user.displayName), + confirm = stringResource(R.string.settings_devices_revoke), + dismiss = stringResource(DesignR.string.common_cancel), + onConfirm = actions.onRevoke, + onDismiss = actions.onDismissRevoke, + destructive = true, + ) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt new file mode 100644 index 0000000..271b75b --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/devices/DevicesViewModel.kt @@ -0,0 +1,107 @@ +package se.hagstedt.kvitt.feature.settings.devices + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.feature.settings.common.NETWORK +import se.hagstedt.kvitt.feature.settings.common.UiMessage +import se.hagstedt.kvitt.feature.settings.common.failureCode +import se.hagstedt.kvitt.feature.settings.common.failureMessage +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.Device +import se.hagstedt.kvitt.model.DeviceRepository +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.SessionRepository +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.User +import javax.inject.Inject + +/** The online list of S-20: loading, the devices, or not available (offline, or the server could not be reached). */ +sealed interface DeviceList { + data object Loading : DeviceList + data class Loaded(val devices: List) : DeviceList + data class Unavailable(val unreachable: Boolean) : DeviceList +} + +/** S-20 (design-spec §4 S-20, AC-3.1, AC-3.2). Revoked devices are not listed. */ +data class DevicesState(val me: User, val partner: User?, val list: DeviceList, val online: Boolean, val confirm: Device?, val busy: Boolean, val message: UiMessage?) + +/** AC-3.2, §8.4: the admin may revoke any device but the current one, a member only their own other devices. */ +fun canRevoke(device: Device, me: User): Boolean = !device.current && !device.revoked && (me.role == Role.ADMIN || device.user.id == me.id) + +@HiltViewModel +class DevicesViewModel @Inject constructor( + private val devices: DeviceRepository, + session: SessionRepository, + profile: ProfileRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + private val local = MutableStateFlow(Local()) + + val state: StateFlow = combine(session.session, profile.household(), connectivity.online, local) { s, household, online, l -> + val paired = s as? SessionState.Paired ?: return@combine null + val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me + DevicesState( + me = me, + partner = household.firstOrNull { it.id != me.id } ?: paired.partner, + list = if (!online && l.list !is DeviceList.Loaded) DeviceList.Unavailable(unreachable = false) else l.list, + online = online, + confirm = l.confirm, + busy = l.busy, + message = l.message, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + init { + viewModelScope.launch { + connectivity.online.distinctUntilChanged().collect { online -> if (online && local.value.list !is DeviceList.Loaded) load() } + } + } + + /** `list()` (online); a network failure shows the unavailable layout with "Försök igen". */ + fun load() { + local.update { it.copy(list = DeviceList.Loading) } + viewModelScope.launch { + val result = devices.list() + val list = when (result) { + is OnlineResult.Ok -> DeviceList.Loaded(result.value.filter { d -> !d.revoked }) + OnlineResult.Offline -> DeviceList.Unavailable(unreachable = false) + is OnlineResult.Failed -> DeviceList.Unavailable(unreachable = result.code == NETWORK) + } + local.update { it.copy(list = list) } + } + } + + fun askRevoke(device: Device) = local.update { it.copy(confirm = device) } + + fun dismissRevoke() = local.update { it.copy(confirm = null) } + + /** Confirmed revoke → the row goes; a refusal (`cannot_revoke_current_device`, network) → snackbar. */ + fun revoke() { + val device = local.value.confirm ?: return + local.update { it.copy(confirm = null, busy = true) } + viewModelScope.launch { + val result = devices.revoke(device.id) + local.update { it.copy(busy = false) } + if (result is OnlineResult.Ok) { + local.update { l -> (l.list as? DeviceList.Loaded)?.let { l.copy(list = DeviceList.Loaded(it.devices.filter { d -> d.id != device.id })) } ?: l } + } else { + result.failureCode()?.let { code -> local.update { it.copy(message = failureMessage(code, canRetry = false)) } } + } + } + } + + fun onMessageShown() = local.update { it.copy(message = null) } + + private data class Local(val list: DeviceList = DeviceList.Loading, val confirm: Device? = null, val busy: Boolean = false, val message: UiMessage? = null) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt new file mode 100644 index 0000000..b2a5ee8 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubScreen.kt @@ -0,0 +1,170 @@ +package se.hagstedt.kvitt.feature.settings.hub + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AvatarPlaceholder +import se.hagstedt.kvitt.design.component.AvatarShape +import se.hagstedt.kvitt.design.component.KvittCard +import se.hagstedt.kvitt.design.component.KvittDestination +import se.hagstedt.kvitt.design.component.KvittDivider +import se.hagstedt.kvitt.design.component.KvittNavigationBar +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.PersonAvatar +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SettingsRow +import se.hagstedt.kvitt.design.component.syncLineText +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.core.design.R as DesignR + +/** S-13 Settings hub. Each row pushes its screen; "Synkstatus" opens S-16 (wired by `:app`). */ +@Composable +fun SettingsHubScreen( + onOpenProfile: () -> Unit, + onOpenCategories: () -> Unit, + onOpenRecurring: () -> Unit, + onOpenDevices: () -> Unit, + onOpenInvites: () -> Unit, + onOpenSyncStatus: () -> Unit, + onOpenAbout: () -> Unit, + onNavigate: (KvittDestination) -> Unit, + modifier: Modifier = Modifier, + viewModel: SettingsHubViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + SettingsHubContent(state, SettingsHubActions(onOpenProfile, onOpenCategories, onOpenRecurring, onOpenDevices, onOpenInvites, onOpenSyncStatus, onOpenAbout, onNavigate), modifier) +} + +/** The navigation of S-13. */ +class SettingsHubActions( + val onOpenProfile: () -> Unit = {}, + val onOpenCategories: () -> Unit = {}, + val onOpenRecurring: () -> Unit = {}, + val onOpenDevices: () -> Unit = {}, + val onOpenInvites: () -> Unit = {}, + val onOpenSyncStatus: () -> Unit = {}, + val onOpenAbout: () -> Unit = {}, + val onNavigate: (KvittDestination) -> Unit = {}, +) + +/** Stateless S-13; [state] null while the session loads (nothing but the bars). */ +@Composable +fun SettingsHubContent(state: SettingsHubState?, actions: SettingsHubActions, modifier: Modifier = Modifier) { + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { KvittTopAppBar(stringResource(R.string.settings_title)) }, + bottomBar = { KvittNavigationBar(KvittDestination.Settings, actions.onNavigate) }, + ) { padding -> + if (state == null) return@SettingsScaffold + ProvideHousehold(state.me, state.partner) { + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap), + ) { + HouseholdCard(state) + SegmentedGroup(3) { item -> + when (item.index) { + 0 -> SettingsRow(item, KvittIcon.Person, ColorToken.ORANGE, stringResource(R.string.settings_profile), actions.onOpenProfile, subtitle = stringResource(R.string.settings_profile_summary)) + 1 -> SettingsRow( + item, KvittIcon.Category, ColorToken.GREEN, stringResource(R.string.settings_categories), actions.onOpenCategories, + subtitle = pluralStringResource(R.plurals.settings_categories_summary, state.activeCategories, state.activeCategories), + ) + else -> SettingsRow(item, KvittIcon.EventRepeat, ColorToken.BROWN, stringResource(R.string.settings_recurring), actions.onOpenRecurring, subtitle = recurringSummary(state)) + } + } + val rows = buildList { + add(HubRow.Devices) + state.inviteRow?.let { add(if (it == InviteRow.InvitePartner) HubRow.InvitePartner else HubRow.PairDevice) } + add(HubRow.Sync) + add(HubRow.About) + } + SegmentedGroup(rows.size) { item -> + when (rows[item.index]) { + HubRow.Devices -> SettingsRow(item, KvittIcon.Devices, ColorToken.BLUE, stringResource(R.string.settings_devices), actions.onOpenDevices) + HubRow.InvitePartner -> SettingsRow(item, KvittIcon.PersonAdd, ColorToken.TEAL, stringResource(DesignR.string.common_invite_partner), actions.onOpenInvites) + HubRow.PairDevice -> SettingsRow(item, KvittIcon.AddLink, ColorToken.TEAL, stringResource(R.string.settings_pair_device), actions.onOpenInvites) + HubRow.Sync -> SettingsRow(item, KvittIcon.Sync, ColorToken.PURPLE, stringResource(R.string.settings_sync_status), actions.onOpenSyncStatus, subtitle = syncLineText(state.sync, state.online)) + HubRow.About -> SettingsRow( + item, KvittIcon.Info, ColorToken.GREY, stringResource(R.string.settings_about), actions.onOpenAbout, + subtitle = if (state.updateAvailable) stringResource(R.string.settings_update_badge) else stringResource(R.string.settings_about_summary, state.versionName), + ) + } + } + } + } + } +} + +private enum class HubRow { Devices, InvitePartner, PairDevice, Sync, About } + +@Composable +private fun recurringSummary(state: SettingsHubState): String { + if (state.activeTemplates == 0 && state.pausedTemplates == 0) return stringResource(R.string.settings_recurring_summary_none) + val parts = listOfNotNull( + pluralStringResource(R.plurals.settings_recurring_summary_active, state.activeTemplates, state.activeTemplates), + if (state.pausedTemplates > 0) pluralStringResource(R.plurals.settings_recurring_summary_paused, state.pausedTemplates, state.pausedTemplates) else null, + ) + return parts.joinToString(stringResource(DesignR.string.common_separator_comma)) +} + +/** Viewer row, divider, partner row (design-spec S-13 point 1). Two TalkBack nodes. */ +@Composable +private fun HouseholdCard(state: SettingsHubState) { + KvittCard(Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = KvittDimens.cardPaddingLarge, vertical = KvittDimens.cardPadding), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + PersonLine(state.me, stringResource(if (state.isAdmin) R.string.settings_role_admin else R.string.settings_role_member)) + KvittDivider() + val partner = state.partner + if (partner == null) { + val missing = stringResource(R.string.settings_partner_missing) + Row(Modifier.clearAndSetSemantics { contentDescription = missing }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap)) { + AvatarPlaceholder(KvittDimens.avatarSettings) + Text(missing, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + PersonLine(partner, stringResource(if (partner.role == Role.ADMIN) R.string.settings_role_admin_other else DesignR.string.common_partner)) + } + } +} + +@Composable +private fun PersonLine(user: User, role: String) { + val spoken = "${user.displayName}${stringResource(DesignR.string.common_separator_comma)}$role" + Row(Modifier.clearAndSetSemantics { contentDescription = spoken }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap)) { + PersonAvatar(user, KvittDimens.avatarSettings, shape = AvatarShape.Large) + Column(Modifier.weight(1f)) { + Text(user.displayName, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(role, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt new file mode 100644 index 0000000..fd9b3ce --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/hub/SettingsHubViewModel.kt @@ -0,0 +1,101 @@ +package se.hagstedt.kvitt.feature.settings.hub + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import se.hagstedt.kvitt.model.AboutRepository +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryRepository +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.RecurringRepository +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.SessionRepository +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.SyncRepository +import se.hagstedt.kvitt.model.SyncSummary +import se.hagstedt.kvitt.model.UpdateRepository +import se.hagstedt.kvitt.model.UpdateState +import se.hagstedt.kvitt.model.User +import javax.inject.Inject + +/** + * S-13 (design-spec §4 S-13): the household card, row subtitles and which rows the viewer gets. [partner] null: the + * partner has not joined (AC-5.1). The invite row is the admin's only (AC-2.1, AC-2.2, §8.4). + */ +data class SettingsHubState( + val me: User, + val partner: User?, + val activeCategories: Int, + val activeTemplates: Int, + val pausedTemplates: Int, + val sync: SyncSummary, + val online: Boolean, + val versionName: String, + val updateAvailable: Boolean, +) { + val isAdmin: Boolean get() = me.role == Role.ADMIN + + /** "Bjud in partner" while the partner has not joined, else "Para ihop en enhet"; null for a member. */ + val inviteRow: InviteRow? get() = when { + !isAdmin -> null + partner == null -> InviteRow.InvitePartner + else -> InviteRow.PairDevice + } +} + +enum class InviteRow { InvitePartner, PairDevice } + +/** Builds the hub state from the repositories; pure, so it is unit-tested without coroutines. */ +fun settingsHubState( + session: SessionState, + household: List, + categories: List, + templates: List, + sync: SyncSummary, + online: Boolean, + versionName: String, + update: UpdateState, +): SettingsHubState? { + val paired = session as? SessionState.Paired ?: return null + val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me + val partner = paired.partner?.let { p -> household.firstOrNull { it.id == p.id } ?: p } + return SettingsHubState( + me = me, + partner = partner, + activeCategories = categories.count { !it.archived }, + activeTemplates = templates.count { !it.paused }, + pausedTemplates = templates.count { it.paused }, + sync = sync, + online = online, + versionName = versionName, + updateAvailable = update !is UpdateState.None, + ) +} + +@HiltViewModel +class SettingsHubViewModel @Inject constructor( + session: SessionRepository, + profile: ProfileRepository, + categories: CategoryRepository, + recurring: RecurringRepository, + sync: SyncRepository, + about: AboutRepository, + update: UpdateRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + private val people = combine(session.session, profile.household()) { s, h -> s to h } + private val lists = combine(categories.all(), recurring.all()) { c, t -> c to t } + private val status = combine(sync.status(), connectivity.online, about.about(), update.state) { s, o, a, u -> Status(s.summary, o, a.versionName, u) } + + val state: StateFlow = combine(people, lists, status) { (s, h), (c, t), st -> + settingsHubState(s, h, c, t, st.sync, st.online, st.versionName, st.update) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private data class Status(val sync: SyncSummary, val online: Boolean, val versionName: String, val update: UpdateState) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt new file mode 100644 index 0000000..bb49864 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesScreen.kt @@ -0,0 +1,283 @@ +package se.hagstedt.kvitt.feature.settings.invites + +import android.content.ClipData +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.ConfirmDialog +import se.hagstedt.kvitt.design.component.InviteCodeDisplay +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittCard +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.LocalKvittClock +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SegmentedItemScope +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.groupInviteCode +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.normalizeInviteCode +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.HintText +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.feature.settings.common.OfflineBanner +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.Invite +import se.hagstedt.kvitt.model.InviteKind +import se.hagstedt.kvitt.model.UserId +import kotlin.time.Instant +import se.hagstedt.kvitt.core.design.R as DesignR + +/** S-21 Invites (admin only; the hub offers it only to the admin, AC-2.1, §8.4). */ +@Composable +fun InvitesScreen(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: InvitesViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() + val shareMessage = state?.created?.let { invite -> stringResource(R.string.settings_invites_share_message, invite.link.orEmpty(), groupInviteCode(normalizeInviteCode(invite.code.orEmpty()))) } + val shareTitle = stringResource(R.string.settings_invites_share) + InvitesContent( + state, + InvitesActions( + onBack = onBack, + onInvitePartner = viewModel::invitePartner, + onInviteDevice = viewModel::inviteDevice, + onShare = { + if (shareMessage != null) { + val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, shareMessage) + context.startActivity(Intent.createChooser(send, shareTitle)) + } + }, + onCopy = { code -> + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(code, code))) + viewModel.onCopied() + } + }, + onAskCancel = viewModel::askCancel, + onDismissCancel = viewModel::dismissCancel, + onCancel = viewModel::cancel, + onRetry = viewModel::onRetry, + onMessageShown = viewModel::onMessageShown, + ), + modifier, + ) +} + +class InvitesActions( + val onBack: () -> Unit = {}, + val onInvitePartner: () -> Unit = {}, + val onInviteDevice: (UserId) -> Unit = {}, + val onShare: () -> Unit = {}, + val onCopy: (String) -> Unit = {}, + val onAskCancel: (Invite) -> Unit = {}, + val onDismissCancel: () -> Unit = {}, + val onCancel: () -> Unit = {}, + val onRetry: () -> Unit = {}, + val onMessageShown: () -> Unit = {}, +) + +/** Stateless S-21. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun InvitesContent(state: InvitesState?, actions: InvitesActions, modifier: Modifier = Modifier) { + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { + val title = if (state != null && state.partner == null) stringResource(DesignR.string.common_invite_partner) else stringResource(R.string.settings_invites_title_device) + KvittTopAppBar(title, navigation = TopBarNavigation.Back, onNavigation = actions.onBack) + }, + message = state?.message, + onMessageShown = actions.onMessageShown, + onRetry = actions.onRetry, + ) { padding -> + if (state == null) return@SettingsScaffold + ProvideHousehold(state.me, state.partner) { + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + if (!state.online) OfflineBanner(stringResource(R.string.settings_invites_offline_banner)) + val created = state.created + if (created?.code != null) { + CreatedCard(state, created, actions) + Text(stringResource(R.string.settings_invites_instructions), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = KvittTheme.spacing.s2)) + } else { + CreateCard(state, actions) + } + state.pending?.let { pending -> PendingSection(state, pending, actions) } + } + state.confirmCancel?.let { + ConfirmDialog( + icon = KvittIcon.Key, + title = stringResource(R.string.settings_invites_cancel_title), + body = stringResource(R.string.settings_invites_code_hidden), + confirm = stringResource(R.string.settings_invites_cancel), + dismiss = stringResource(DesignR.string.common_close), + onConfirm = actions.onCancel, + onDismiss = actions.onDismissCancel, + destructive = true, + ) + } + } + } +} + +/** Before an invite is created in this visit: the explanation and the create buttons (AC-2.1, AC-2.2). */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CreateCard(state: InvitesState, actions: InvitesActions) { + val enabled = state.online && !state.busy + KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + Text(stringResource(R.string.settings_invites_create_body), style = MaterialTheme.typography.bodyMedium) + FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + val partner = state.partner + if (partner == null) { + KvittButton(stringResource(DesignR.string.common_invite_partner), actions.onInvitePartner, icon = KvittIcon.PersonAdd, enabled = enabled) + } else { + KvittButton(stringResource(R.string.settings_invites_for_me), { actions.onInviteDevice(state.me.id) }, kind = ButtonKind.Tonal, icon = KvittIcon.AddLink, enabled = enabled) + KvittButton(stringResource(R.string.settings_invites_for_partner, partner.displayName), { actions.onInviteDevice(partner.id) }, kind = ButtonKind.Tonal, icon = KvittIcon.AddLink, enabled = enabled) + } + } + } +} + +/** The created invite (design-spec S-21 point 2): code, link, expiry, "Dela" and "Kopiera kod". */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun CreatedCard(state: InvitesState, invite: Invite, actions: InvitesActions) { + val scheme = MaterialTheme.colorScheme + val content = scheme.onTertiaryContainer + val title = if (invite.kind == InviteKind.PARTNER) stringResource(R.string.settings_invites_new_partner) else stringResource(R.string.settings_invites_new_device_for, state.userOf(invite.targetUser)?.displayName.orEmpty()) + KvittCard( + Modifier.fillMaxWidth(), + color = scheme.tertiaryContainer, + contentPadding = PaddingValues(KvittDimens.cardPaddingLarge), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + Row(Modifier.semantics { heading() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + KvittIconImage(KvittIcon.Key, null, filled = true, tint = content) + Text(title, style = MaterialTheme.typography.titleMedium, color = content) + } + Text(stringResource(DesignR.string.common_invite_code), style = MaterialTheme.typography.labelMedium, color = content) + InviteCodeDisplay(invite.code.orEmpty()) + Text(invite.link.orEmpty(), Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall.copy(lineBreak = LineBreak.Simple), color = content, textAlign = TextAlign.Center) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) { + KvittIconImage(KvittIcon.Schedule, null, tint = content) + Text(stringResource(R.string.settings_invites_valid_until, expiry(invite.expiresAt)), style = MaterialTheme.typography.bodyMedium, color = content) + } + FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittButton(stringResource(R.string.settings_invites_share), actions.onShare, kind = ButtonKind.Tertiary, icon = KvittIcon.Share) + KvittButton(stringResource(R.string.settings_invites_copy_code), { actions.onCopy(groupInviteCode(normalizeInviteCode(invite.code.orEmpty()))) }, kind = ButtonKind.Outlined, icon = KvittIcon.ContentCopy, contentColor = content) + } + } +} + +/** "Väntande inbjudningar" with a cancel per row (AC-2.11). */ +@Composable +private fun PendingSection(state: InvitesState, pending: List, actions: InvitesActions) { + Text( + stringResource(R.string.settings_invites_pending), + Modifier.padding(start = KvittTheme.spacing.s2, top = KvittTheme.spacing.s3).semantics { heading() }, + style = MaterialTheme.typography.titleSmall, + ) + if (pending.isEmpty()) { + HintText(stringResource(R.string.settings_invites_none), Modifier.padding(horizontal = KvittTheme.spacing.s2)) + return + } + SegmentedGroup(pending.size) { item -> PendingRow(item, state, pending[item.index], actions) } + HintText(stringResource(R.string.settings_invites_code_hidden), Modifier.padding(horizontal = KvittTheme.spacing.s2)) +} + +@Composable +private fun PendingRow(item: SegmentedItemScope, state: InvitesState, invite: Invite, actions: InvitesActions) { + val colors = KvittTheme.colors + val title = if (invite.kind == InviteKind.PARTNER) stringResource(R.string.settings_invites_kind_partner) else stringResource(R.string.settings_invites_kind_device, state.userOf(invite.targetUser)?.displayName.orEmpty()) + val created = stringResource(R.string.settings_invites_created, createdAt(invite.createdAt)) + val valid = stringResource(R.string.settings_invites_valid_until, expiry(invite.expiresAt)) + val line = created + stringResource(DesignR.string.common_separator_dot) + valid + val spoken = title + stringResource(DesignR.string.common_separator_comma) + valid + Row( + Modifier.fillMaxWidth().background(item.fill, item.shape).heightIn(min = KvittTheme.spacing.ledgerRowMin).padding(horizontal = KvittTheme.spacing.s4, vertical = KvittTheme.spacing.s3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + Box(Modifier.size(KvittDimens.tileLedger).background(colors.categoryContainer(ColorToken.YELLOW), KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) { + KvittIconImage(KvittIcon.Key, null, filled = true, size = KvittDimens.settingsTileIcon, tint = colors.category(ColorToken.YELLOW)) + } + Column(Modifier.weight(1f).clearAndSetSemantics { contentDescription = spoken }) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text(line, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + val cancelLabel = stringResource(R.string.settings_invites_cancel) + KvittButton( + stringResource(DesignR.string.common_cancel), + { actions.onAskCancel(invite) }, + Modifier.clearAndSetSemantics { contentDescription = cancelLabel }, + kind = ButtonKind.Text, + enabled = state.online && !state.busy, + ) + } +} + +/** "tis 20 okt. 14:02" (§9.8 weekday, list date and time). */ +@Composable +private fun expiry(rfc3339: String): String { + val clock = LocalKvittClock.current + return DateFormatter.weekdayDateTime(Instant.parse(rfc3339), clock.zone, clock.today, kvittLanguage()) +} + +/** "idag 14:02" for today, else "16 okt. 18:30". */ +@Composable +private fun createdAt(rfc3339: String): String { + val clock = LocalKvittClock.current + val instant = Instant.parse(rfc3339) + val language = kvittLanguage() + val local = DateFormatter.listDateTime(instant, clock.zone, clock.today, language) + val sameDay = DateFormatter.listDate(clock.today, clock.today, language) == local.substringBeforeLast(' ') + return if (sameDay) stringResource(DesignR.string.time_today_at, DateFormatter.time(instant, clock.zone)) else local +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesViewModel.kt new file mode 100644 index 0000000..08ecdb0 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/invites/InvitesViewModel.kt @@ -0,0 +1,145 @@ +package se.hagstedt.kvitt.feature.settings.invites + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.UiMessage +import se.hagstedt.kvitt.feature.settings.common.failureCode +import se.hagstedt.kvitt.feature.settings.common.failureMessage +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.Invite +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.SessionRepository +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId +import javax.inject.Inject + +/** + * S-21 (design-spec §4 S-21, admin only; AC-2.1, AC-2.2, AC-2.11). [created] is the invite made in this visit, the + * only time its code and link exist; leaving the screen drops it. [pending] null: not loaded (offline or failed). + */ +data class InvitesState( + val me: User, + val partner: User?, + val online: Boolean, + val pending: List?, + val created: Invite?, + val busy: Boolean, + val confirmCancel: Invite?, + val message: UiMessage?, +) + +/** Who a device invite is for, by id. */ +fun InvitesState.userOf(id: UserId?): User? = listOfNotNull(me, partner).firstOrNull { it.id == id } + +@HiltViewModel +class InvitesViewModel @Inject constructor( + private val invites: InviteRepository, + session: SessionRepository, + profile: ProfileRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + private val local = MutableStateFlow(Local()) + private var retry: (() -> Unit)? = null + + val state: StateFlow = combine(session.session, profile.household(), connectivity.online, local) { s, household, online, l -> + val paired = s as? SessionState.Paired ?: return@combine null + val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me + InvitesState( + me = me, + partner = household.firstOrNull { it.id != me.id } ?: paired.partner, + online = online, + pending = if (online) l.pending else null, + created = l.created, + busy = l.busy, + confirmCancel = l.confirmCancel, + message = l.message, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + init { + viewModelScope.launch { + connectivity.online.distinctUntilChanged().collect { online -> if (online) load() } + } + } + + /** Pending invites (online; codes are never listed). */ + fun load() { + viewModelScope.launch { + val result = invites.list() + if (result is OnlineResult.Ok) local.update { it.copy(pending = result.value.sortedBy { i -> i.createdAt }) } + } + } + + /** "Bjud in partner" (AC-2.1). */ + fun invitePartner() = create(InviteKind.PARTNER, null) + + /** "För mig" / "För Anna" (AC-2.2). */ + fun inviteDevice(user: UserId) = create(InviteKind.DEVICE, user) + + private fun create(kind: InviteKind, target: UserId?) { + if (local.value.busy) return + retry = { create(kind, target) } + local.update { it.copy(busy = true, created = null) } + viewModelScope.launch { + val result = invites.create(kind, target) + local.update { it.copy(busy = false) } + when (result) { + is OnlineResult.Ok -> { + local.update { it.copy(created = result.value) } + load() + } + else -> result.failureCode()?.let { code -> local.update { it.copy(message = failureMessage(code)) } } + } + } + } + + /** "Kopiera kod" done: "Koden kopierades". */ + fun onCopied() = local.update { it.copy(message = UiMessage(R.string.settings_invites_code_copied)) } + + fun askCancel(invite: Invite) = local.update { it.copy(confirmCancel = invite) } + + fun dismissCancel() = local.update { it.copy(confirmCancel = null) } + + /** Confirmed "Avbryt": `cancel(id)`; the row goes. */ + fun cancel() { + val invite = local.value.confirmCancel ?: return + local.update { it.copy(confirmCancel = null, busy = true) } + viewModelScope.launch { + val result = invites.cancel(invite.id) + local.update { it.copy(busy = false) } + if (result is OnlineResult.Ok) { + local.update { l -> l.copy(pending = l.pending?.filter { it.id != invite.id }, created = l.created?.takeIf { it.id != invite.id }) } + } else { + result.failureCode()?.let { code -> local.update { it.copy(message = failureMessage(code, canRetry = false)) } } + } + } + } + + fun onRetry() { + retry?.invoke() + } + + fun onMessageShown() = local.update { it.copy(message = null) } + + private data class Local( + val pending: List? = null, + val created: Invite? = null, + val busy: Boolean = false, + val confirmCancel: Invite? = null, + val message: UiMessage? = null, + ) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt new file mode 100644 index 0000000..541f205 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileScreen.kt @@ -0,0 +1,173 @@ +package se.hagstedt.kvitt.feature.settings.profile + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AvatarShape +import se.hagstedt.kvitt.design.component.AvatarState +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittTextField +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.PersonAvatar +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.feature.settings.common.OfflineBanner +import se.hagstedt.kvitt.feature.settings.common.SaveAction +import java.io.IOException +import se.hagstedt.kvitt.core.design.R as DesignR + +/** S-14 Profile. [onBack] after a saved name and on the back arrow. */ +@Composable +fun ProfileScreen(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + val context = LocalContext.current + val scope = rememberCoroutineScope() + val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri -> + if (uri != null) { + scope.launch { + val picked = withContext(Dispatchers.IO) { readPicture(context, uri) } + if (picked == null) viewModel.onPictureUnreadable() else viewModel.onPicture(picked.first, picked.second) + } + } + } + LaunchedEffect(state?.done) { if (state?.done == true) onBack() } + ProfileContent( + state, + ProfileActions( + onBack = onBack, + onName = viewModel::onName, + onSave = viewModel::save, + onPickPicture = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) }, + onRemovePicture = viewModel::removePicture, + onRetry = viewModel::onRetry, + onMessageShown = viewModel::onMessageShown, + ), + modifier, + ) +} + +/** The picked image's bytes and MIME type, or null when the file cannot be read. */ +private fun readPicture(context: Context, uri: Uri): Pair? { + val resolver = context.contentResolver + val type = resolver.getType(uri) ?: return null + return try { + resolver.openInputStream(uri)?.use { it.readBytes() }?.let { it to type } + } catch (e: IOException) { + Log.w("Profile", "picture unreadable: ${e.javaClass.simpleName}") + null + } catch (e: SecurityException) { + Log.w("Profile", "picture not permitted: ${e.javaClass.simpleName}") + null + } +} + +class ProfileActions( + val onBack: () -> Unit = {}, + val onName: (String) -> Unit = {}, + val onSave: () -> Unit = {}, + val onPickPicture: () -> Unit = {}, + val onRemovePicture: () -> Unit = {}, + val onRetry: () -> Unit = {}, + val onMessageShown: () -> Unit = {}, +) + +/** Stateless S-14. */ +@Composable +fun ProfileContent(state: ProfileState?, actions: ProfileActions, modifier: Modifier = Modifier) { + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { + KvittTopAppBar(stringResource(R.string.settings_profile), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) { + SaveAction(enabled = state?.canSave == true, onClick = actions.onSave) + } + }, + message = state?.message, + onMessageShown = actions.onMessageShown, + onRetry = actions.onRetry, + ) { padding -> + if (state == null) return@SettingsScaffold + ProvideHousehold(state.me, state.partner) { + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter), + verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap), + ) { + if (!state.online) OfflineBanner(stringResource(R.string.settings_profile_offline_banner)) + Column( + Modifier.fillMaxWidth().padding(top = KvittTheme.spacing.s5, bottom = KvittTheme.spacing.detailGap), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + PersonAvatar( + state.me, + KvittDimens.avatarProfile, + shape = AvatarShape.Large, + state = if (state.uploading) AvatarState.Uploading else AvatarState.Idle, + contentDescription = state.me.displayName, + ) + KvittButton( + stringResource(if (state.hasPicture) DesignR.string.profile_change_picture else DesignR.string.profile_add_picture), + actions.onPickPicture, + kind = ButtonKind.Tonal, + icon = KvittIcon.PhotoLibrary, + enabled = state.pictureEnabled, + ) + if (state.hasPicture) { + KvittButton(stringResource(DesignR.string.profile_remove_picture), actions.onRemovePicture, kind = ButtonKind.Text, icon = KvittIcon.Delete, enabled = state.pictureEnabled) + } + } + KvittTextField( + value = state.name, + onValueChange = actions.onName, + label = stringResource(DesignR.string.common_name), + error = state.nameError?.let { nameErrorText(it) }, + supporting = state.partner?.let { stringResource(R.string.settings_profile_hint, it.displayName) }, + capitalization = KeyboardCapitalization.Words, + onIme = actions.onSave, + enabled = state.online && !state.saving, + ) + } + } + } +} + +@Composable +private fun nameErrorText(error: NameError): String = when (error) { + NameError.Required -> stringResource(DesignR.string.common_name_required) + is NameError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, error.max, error.max) + NameError.Refused -> stringResource(DesignR.string.error_validation) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt new file mode 100644 index 0000000..cfc2877 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/profile/ProfileViewModel.kt @@ -0,0 +1,153 @@ +package se.hagstedt.kvitt.feature.settings.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.feature.settings.common.UiMessage +import se.hagstedt.kvitt.feature.settings.common.failureCode +import se.hagstedt.kvitt.feature.settings.common.failureMessage +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.SessionRepository +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.User +import javax.inject.Inject +import se.hagstedt.kvitt.core.design.R as DesignR + +/** Why the name field is refused (§9.6: 1 to 40 code points). */ +sealed interface NameError { + data object Required : NameError + data class TooLong(val max: Int) : NameError + data object Refused : NameError // validation_failed on display_name from the server +} + +/** §9.6 limit of a display name. */ +const val NAME_MAX = 40 + +/** The local check of a display name, or null when it may be sent. */ +fun nameError(name: String): NameError? { + val trimmed = name.trim() + val length = trimmed.codePointCount(0, trimmed.length) + return when { + length == 0 -> NameError.Required + length > NAME_MAX -> NameError.TooLong(NAME_MAX) + else -> null + } +} + +/** S-14 (design-spec §4 S-14): own name and picture, both online only (AC-4.2, AC-4.3, §9.9). */ +data class ProfileState( + val me: User, + val partner: User?, + val name: String, + val nameError: NameError?, + val online: Boolean, + val uploading: Boolean, + val saving: Boolean, + val message: UiMessage?, + val done: Boolean, +) { + val hasPicture: Boolean get() = me.pictureVersion != null + val canSave: Boolean get() = online && !saving && !uploading && nameError == null && name.trim() != me.displayName + val pictureEnabled: Boolean get() = online && !uploading +} + +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val profile: ProfileRepository, + session: SessionRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + private val form = MutableStateFlow(Form()) + private var retry: (() -> Unit)? = null + + val state: StateFlow = combine(session.session, profile.household(), connectivity.online, form) { s, household, online, f -> + val paired = s as? SessionState.Paired ?: return@combine null + val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me + val partner = household.firstOrNull { it.id != me.id } ?: paired.partner + val name = f.name ?: me.displayName + ProfileState( + me = me, + partner = partner, + name = name, + nameError = f.serverNameError ?: if (f.name != null) nameError(name) else null, + online = online, + uploading = f.uploading, + saving = f.saving, + message = f.message, + done = f.done, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + fun onName(name: String) = form.update { it.copy(name = name, serverNameError = null) } + + /** "Spara": `setDisplayName`; success returns to the hub without a snackbar (design-spec S-14). */ + fun save() { + val current = state.value ?: return + if (!current.canSave) return + form.update { it.copy(saving = true) } + viewModelScope.launch { + val result = profile.setDisplayName(current.name.trim()) + form.update { it.copy(saving = false) } + when { + result is OnlineResult.Ok -> form.update { it.copy(done = true) } + result is OnlineResult.Failed && result.code == "validation_failed" -> form.update { it.copy(serverNameError = NameError.Refused) } + else -> fail(result) { save() } + } + } + } + + /** A picture from the photo picker (AC-4.3): applied on success without "Spara". */ + fun onPicture(bytes: ByteArray, mimeType: String) { + retry = { onPicture(bytes, mimeType) } + upload { profile.setPicture(bytes, mimeType) } + } + + /** "Ta bort bild": no question asked, adding again undoes it (design-spec S-14). */ + fun removePicture() { + retry = { removePicture() } + upload { profile.removePicture() } + } + + /** The picked file could not be read on the phone. */ + fun onPictureUnreadable() = form.update { it.copy(message = UiMessage(DesignR.string.profile_picture_failed)) } + + fun onRetry() { + retry?.invoke() + } + + fun onMessageShown() = form.update { it.copy(message = null) } + + private fun upload(call: suspend () -> OnlineResult) { + if (form.value.uploading) return + form.update { it.copy(uploading = true) } + viewModelScope.launch { + val result = call() + form.update { it.copy(uploading = false) } + if (result !is OnlineResult.Ok) fail(result, null) + } + } + + private fun fail(result: OnlineResult<*>, again: (() -> Unit)?) { + val code = result.failureCode() ?: return + if (again != null) retry = again + form.update { it.copy(message = failureMessage(code)) } + } + + private data class Form( + val name: String? = null, + val serverNameError: NameError? = null, + val uploading: Boolean = false, + val saving: Boolean = false, + val message: UiMessage? = null, + val done: Boolean = false, + ) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/Periods.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/Periods.kt new file mode 100644 index 0000000..36fc1e6 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/Periods.kt @@ -0,0 +1,34 @@ +package se.hagstedt.kvitt.feature.settings.recurring + +/** `YYYY-MM` periods (§14.2.4 `startPeriod`, `endPeriod`) as plain arithmetic. */ +object Periods { + fun of(year: Int, month: Int): String = "$year-${month.toString().padStart(2, '0')}" + + fun year(period: String): Int = period.substringBefore('-').toInt() + + fun month(period: String): Int = period.substringAfter('-').take(2).toInt() + + /** [period] moved by [months] (negative: back). */ + fun plus(period: String, months: Int): String { + val index = year(period) * 12 + (month(period) - 1) + months + return of(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1) + } + + /** The month of an ISO date `YYYY-MM-DD`. */ + fun ofDate(isoDate: String): String = isoDate.take(7) + + /** + * AC-9.8: the default first month is the current month when [dayOfMonth] is today or later (phone's zone), else the + * next month. Without a valid day the current month. + */ + fun defaultStart(today: String, dayOfMonth: Int?): String { + val current = ofDate(today) + val day = today.substring(8, 10).toInt() + return if (dayOfMonth == null || dayOfMonth >= day) current else plus(current, 1) + } + + /** AC-9.8: at most 12 months back (backfill). */ + fun earliestStart(today: String): String = plus(ofDate(today), -MAX_BACKFILL_MONTHS) + + const val MAX_BACKFILL_MONTHS = 12 +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt new file mode 100644 index 0000000..77bf4fd --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt @@ -0,0 +1,451 @@ +package se.hagstedt.kvitt.feature.settings.recurring + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.window.Dialog +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittElevation +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AccountChip +import se.hagstedt.kvitt.design.component.AccountPicker +import se.hagstedt.kvitt.design.component.AmountDisplay +import se.hagstedt.kvitt.design.component.AmountKeypad +import se.hagstedt.kvitt.design.component.ButtonKind +import se.hagstedt.kvitt.design.component.CategoryGrid +import se.hagstedt.kvitt.design.component.CategoryQuickRow +import se.hagstedt.kvitt.design.component.ConfirmDialog +import se.hagstedt.kvitt.design.component.DescriptionField +import se.hagstedt.kvitt.design.component.EditorChipRow +import se.hagstedt.kvitt.design.component.ExactSplitEditor +import se.hagstedt.kvitt.design.component.FullSplitPicker +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittTextField +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.NotesField +import se.hagstedt.kvitt.design.component.PayerChip +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.RatioEditor +import se.hagstedt.kvitt.design.component.RatioField +import se.hagstedt.kvitt.design.component.SplitBar +import se.hagstedt.kvitt.design.component.SplitChip +import se.hagstedt.kvitt.design.component.SplitPreview +import se.hagstedt.kvitt.design.component.SplitSection +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.defaultModeLabel +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.splitModeLabel +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.format.KvittLanguage +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.HintText +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.feature.settings.common.OfflineBanner +import se.hagstedt.kvitt.feature.settings.common.SaveAction +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.UserId +import se.hagstedt.kvitt.core.design.R as DesignR + +/** S-19 recurring editor. [onClose] after save, delete, discard or close without changes. */ +@Composable +fun RecurringEditorScreen(onClose: () -> Unit, modifier: Modifier = Modifier, viewModel: RecurringEditorViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(state?.done) { if (state?.done == true) onClose() } + RecurringEditorContent( + state, + RecurringEditorActions( + onClose = viewModel::close, + onSave = viewModel::save, + onKey = viewModel::onKey, + onFocus = viewModel::focus, + onDescription = viewModel::onDescription, + onNotes = viewModel::onNotes, + onOpenNotes = viewModel::openNotes, + onCategory = viewModel::onCategory, + onAccount = viewModel::onAccount, + onTogglePayer = viewModel::togglePayer, + onToggleSplit = viewModel::toggleSplit, + onMode = viewModel::onMode, + onViewerPercent = viewModel::onViewerPercent, + onRatioFocus = viewModel::onRatioFocus, + onExactTyped = viewModel::onExactTyped, + onFullOn = viewModel::onFullOn, + onDay = viewModel::onDay, + onPaused = viewModel::onPaused, + onOpenSheet = viewModel::openSheet, + onCloseSheet = viewModel::closeSheet, + onOpenMonth = viewModel::openMonth, + onMonth = viewModel::onMonth, + onDismissDialog = viewModel::dismissDialog, + onDiscard = viewModel::discard, + onAskDelete = viewModel::askDelete, + onDelete = viewModel::delete, + onRetry = viewModel::onRetry, + onMessageShown = viewModel::onMessageShown, + ), + modifier, + ) +} + +class RecurringEditorActions( + val onClose: () -> Unit = {}, + val onSave: () -> Unit = {}, + val onKey: (AmountKey) -> Unit = {}, + val onFocus: (EditorFocus) -> Unit = {}, + val onDescription: (String) -> Unit = {}, + val onNotes: (String) -> Unit = {}, + val onOpenNotes: () -> Unit = {}, + val onCategory: (CategoryId) -> Unit = {}, + val onAccount: (AccountId) -> Unit = {}, + val onTogglePayer: () -> Unit = {}, + val onToggleSplit: () -> Unit = {}, + val onMode: (SplitMode) -> Unit = {}, + val onViewerPercent: (Int) -> Unit = {}, + val onRatioFocus: (RatioField) -> Unit = {}, + val onExactTyped: (UserId) -> Unit = {}, + val onFullOn: (UserId) -> Unit = {}, + val onDay: (String) -> Unit = {}, + val onPaused: (Boolean) -> Unit = {}, + val onOpenSheet: (EditorSheet) -> Unit = {}, + val onCloseSheet: () -> Unit = {}, + val onOpenMonth: (MonthField) -> Unit = {}, + val onMonth: (MonthField, String?) -> Unit = { _, _ -> }, + val onDismissDialog: () -> Unit = {}, + val onDiscard: () -> Unit = {}, + val onAskDelete: () -> Unit = {}, + val onDelete: () -> Unit = {}, + val onRetry: () -> Unit = {}, + val onMessageShown: () -> Unit = {}, +) + +/** Stateless S-19 editor. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecurringEditorContent(state: RecurringEditorState?, actions: RecurringEditorActions, modifier: Modifier = Modifier) { + val keypad = state != null && state.form.focus != EditorFocus.None + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { + val title = if (state?.isNew != false) R.string.settings_recurring_editor_new else R.string.settings_recurring_editor_edit + KvittTopAppBar(stringResource(title), navigation = TopBarNavigation.Close, onNavigation = actions.onClose) { + SaveAction(enabled = state?.canSave == true, onClick = actions.onSave) + } + }, + bottomBar = { + if (keypad) AmountKeypad(actions.onKey, Modifier.navigationBarsPadding(), compact = state.form.splitOpen) + }, + message = state?.message, + onMessageShown = actions.onMessageShown, + onRetry = actions.onRetry, + ) { padding -> + if (state == null) return@SettingsScaffold + ProvideHousehold(state.options.me, state.options.partner) { + EditorBody(state, actions, Modifier.padding(padding)) + when (state.sheet) { + EditorSheet.Category -> ModalBottomSheet(actions.onCloseSheet) { + CategoryGrid(state.options.categories, state.form.categoryId, { actions.onCategory(it.id) }, Modifier.padding(KvittTheme.spacing.gutter)) + } + EditorSheet.Account -> ModalBottomSheet(actions.onCloseSheet) { + AccountPicker(state.choices, state.options.partner, state.form.accountId, { actions.onAccount(it.summary.account.id) }, Modifier.padding(KvittTheme.spacing.gutter)) + } + null -> Unit + } + EditorDialogs(state, actions) + } + } +} + +@Composable +private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActions, modifier: Modifier) { + val language = kvittLanguage() + val form = state.form + val me = state.options.me + val partner = state.options.partner + Column( + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap), + ) { + if (!state.online) OfflineBanner(stringResource(R.string.settings_recurring_offline_banner)) + AmountDisplay( + form.amount.display(language), + focused = form.focus == EditorFocus.Amount, + error = state.errors.amount?.let { stringResource(if (it == AmountError.Required) DesignR.string.error_amount_required else DesignR.string.error_validation) }, + onClick = { actions.onFocus(EditorFocus.Amount) }, + ) + DescriptionField( + form.description, + actions.onDescription, + error = state.errors.description?.let { descriptionError(it) }, + onDone = { actions.onFocus(EditorFocus.None) }, + modifier = Modifier.onFocusChanged { if (it.isFocused) actions.onFocus(EditorFocus.None) }, + ) + CategoryQuickRow(state.quickRow, form.categoryId, { actions.onCategory(it.id) }, { actions.onOpenSheet(EditorSheet.Category) }) + val payer = if ((form.payer ?: me.id) == me.id) me else partner + val viewerPercent = state.viewerDefaultPercent + val defaultLabel = defaultModeLabel(viewerPercent) + EditorChipRow { + AccountChip(state.account?.name.orEmpty(), state.accountColorIndex, { actions.onOpenSheet(EditorSheet.Account) }) + PayerChip(payer, if (payer == me) partner else me, actions.onTogglePayer) + SplitChip( + if (form.mode == SplitMode.RATIO) stringResource(DesignR.string.editor_split_mode_ratio) + " " + stringResource(DesignR.string.common_ratio, (form.viewerBp / 100).toString(), ((10_000 - form.viewerBp) / 100).toString()) else splitModeLabel(form.mode, defaultLabel), + form.splitOpen, + actions.onToggleSplit, + ) + } + if (form.splitOpen) { + SplitSection(form.mode, defaultLabel, actions.onMode, actions.onToggleSplit) { + when (form.mode) { + SplitMode.DEFAULT -> Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + SplitBar(viewerPercent.toFloat()) + SplitPreview(state.shares, me, partner) + } + SplitMode.RATIO -> { + val viewerTyped = form.ratioTyped == RatioField.Viewer + val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.of(form.viewerBp.toLong()).display(language) + val partnerText = if (viewerTyped) AmountInput.of((10_000 - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language) + val focused = when (form.focus) { + EditorFocus.RatioViewer -> RatioField.Viewer + EditorFocus.RatioPartner -> RatioField.Partner + else -> null + } + RatioEditor(me, partner, form.viewerBp / 100, viewerText, partnerText, focused, actions.onViewerPercent, actions.onRatioFocus, actions.onViewerPercent, state.shares) + } + SplitMode.EXACT -> { + val typed = form.exactTyped ?: me.id + val typedOre = form.exactInput.ore + ExactSplitEditor( + me, partner, typed, form.exactInput.display(language), + derivedOre = typedOre?.let { t -> form.amount.ore?.let { (it - t).takeIf { d -> d >= 0 } } }, + focused = form.focus == EditorFocus.Exact, + amountOre = form.amount.ore, + typedOre = typedOre, + onTypedChange = actions.onExactTyped, + onFocus = { actions.onFocus(EditorFocus.Exact) }, + ) + } + SplitMode.FULL -> FullSplitPicker(me, partner, form.fullOn ?: me.id, actions.onFullOn, state.shares) + } + } + } + NotesField(form.notes, actions.onNotes, form.notesOpen, actions.onOpenNotes) + ScheduleSection(state, actions) + if (!state.isNew) { + KvittButton(stringResource(DesignR.string.common_delete), actions.onAskDelete, kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = state.online && !state.busy) + } + } +} + +/** "Schema" (design-spec S-19): day and first month (1 : 1.5), last month, hints, the paused switch. */ +@Composable +private fun ScheduleSection(state: RecurringEditorState, actions: RecurringEditorActions) { + val form = state.form + val language = kvittLanguage() + Column( + Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card).padding(KvittTheme.spacing.s4), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + Text(stringResource(R.string.settings_recurring_schedule), style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), verticalAlignment = Alignment.Top) { + KvittTextField( + value = form.day, + onValueChange = actions.onDay, + label = stringResource(R.string.settings_recurring_day_of_month), + modifier = Modifier.weight(1f).onFocusChanged { if (it.isFocused) actions.onFocus(EditorFocus.None) }, + error = if (state.errors.day && form.dayOfMonth != null) stringResource(DesignR.string.error_validation) else null, + keyboardType = KeyboardType.Number, + enabled = state.online, + ) + PickerField( + stringResource(R.string.settings_recurring_first_month), + DateFormatter.monthYear(state.startPeriod, language), + error = state.errors.start?.let { stringResource(if (it == StartError.TooEarly) R.string.settings_recurring_error_start_too_early else DesignR.string.error_validation) }, + enabled = state.online, + onClick = { actions.onOpenMonth(MonthField.First) }, + modifier = Modifier.weight(WIDE_WEIGHT), + ) + } + PickerField( + stringResource(R.string.settings_recurring_last_month), + form.end?.let { DateFormatter.monthYear(it, language) } ?: stringResource(R.string.settings_recurring_no_end), + error = if (state.errors.end) stringResource(R.string.settings_recurring_error_end_before_start) else null, + enabled = state.online, + onClick = { actions.onOpenMonth(MonthField.Last) }, + ) + if (state.dayClampHint) HintText(stringResource(R.string.settings_recurring_day_clamp_hint), Modifier.padding(horizontal = KvittTheme.spacing.s2)) + if (!state.isNew) HintText(stringResource(R.string.settings_recurring_edit_hint), Modifier.padding(horizontal = KvittTheme.spacing.s2)) + if (state.resumeHint) HintText(stringResource(R.string.settings_recurring_resume_hint), Modifier.padding(horizontal = KvittTheme.spacing.s2)) + Row(Modifier.fillMaxWidth().padding(start = KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.settings_recurring_paused_switch), style = MaterialTheme.typography.titleMedium) + HintText(stringResource(R.string.settings_recurring_created_at, dayOrdinal(form.dayOfMonth ?: 1))) + } + Switch(form.paused, actions.onPaused, enabled = state.online) + } + } +} + +/** First month : day = 1.5 : 1 (design-spec S-19). */ +private const val WIDE_WEIGHT = 1.5f + +/** A filled field that opens a picker: label over value, `expand_more` (the month fields of S-19). */ +@Composable +private fun PickerField(label: String, value: String, error: String?, enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val spoken = listOfNotNull(label, value, error).joinToString(stringResource(DesignR.string.common_separator_comma)) + Column(modifier) { + Row( + Modifier + .fillMaxWidth() + .clip(KvittTheme.shapes.field) + .background(scheme.surfaceContainerHighest) + .clickable(enabled = enabled, onClick = onClick) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button } + .heightIn(min = KvittTheme.spacing.rowMin) + .padding(horizontal = KvittTheme.spacing.s4, vertical = KvittTheme.spacing.s2), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodySmall, color = if (error != null) scheme.error else scheme.onSurfaceVariant) + Text(value, style = MaterialTheme.typography.bodyLarge, color = if (enabled) scheme.onSurface else scheme.onSurfaceVariant) + } + KvittIconImage(KvittIcon.ExpandMore, null, tint = scheme.onSurfaceVariant) + } + if (error != null) { + Row(Modifier.padding(start = KvittTheme.spacing.s4, top = KvittTheme.spacing.s1), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + KvittIconImage(KvittIcon.Error, null, filled = true, size = KvittTheme.spacing.s4, tint = scheme.error) + Text(error, style = MaterialTheme.typography.bodySmall, color = scheme.error) + } + } + } +} + +@Composable +private fun EditorDialogs(state: RecurringEditorState, actions: RecurringEditorActions) { + when (val dialog = state.dialog) { + EditorDialog.Discard -> ConfirmDialog( + icon = KvittIcon.Close, + title = stringResource(DesignR.string.common_discard_title), + body = "", + confirm = stringResource(DesignR.string.common_discard), + dismiss = stringResource(DesignR.string.common_keep_editing), + onConfirm = actions.onDiscard, + onDismiss = actions.onDismissDialog, + destructive = true, + ) + EditorDialog.Delete -> ConfirmDialog( + icon = KvittIcon.Delete, + title = stringResource(R.string.settings_recurring_delete_title, state.form.description.trim()), + body = stringResource(R.string.settings_recurring_delete_body), + confirm = stringResource(DesignR.string.common_delete), + dismiss = stringResource(DesignR.string.common_cancel), + onConfirm = actions.onDelete, + onDismiss = actions.onDismissDialog, + destructive = true, + ) + is EditorDialog.Month -> { + // The dialog window has its own configuration; the screen's language is passed in. + val language = kvittLanguage() + Dialog(actions.onDismissDialog) { MonthPickerContent(state, dialog.which, language, actions) } + } + null -> Unit + } +} + +/** + * The month picker (design-spec S-19): titled with the field label, a year row and 12 month chips; months before the + * allowed start (AC-9.8: 12 months back; for the last month: before the first month) are disabled. The last month + * also offers "Ingen slutmånad". + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MonthPickerContent(state: RecurringEditorState, which: MonthField, language: KvittLanguage, actions: RecurringEditorActions) { + val minimum = if (which == MonthField.First) state.earliestStart else state.startPeriod + val selected = if (which == MonthField.First) state.startPeriod else state.form.end + val shownYear = remember(which) { mutableIntStateOf(Periods.year(selected ?: state.startPeriod)) } + val firstYear = Periods.year(minimum) + val years = (firstYear..Periods.year(state.options.today) + YEARS_AHEAD).toList() + Surface(shape = KvittTheme.shapes.card, color = MaterialTheme.colorScheme.surfaceContainerHigh, shadowElevation = KvittElevation.overlay) { + Column(Modifier.padding(KvittTheme.spacing.s6), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + Text(stringResource(if (which == MonthField.First) R.string.settings_recurring_first_month else R.string.settings_recurring_last_month), style = MaterialTheme.typography.headlineSmall) + FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + years.forEach { year -> + FilterChip(selected = year == shownYear.intValue, onClick = { shownYear.intValue = year }, label = { Text(year.toString()) }) + } + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), maxItemsInEachRow = MONTH_COLUMNS) { + (1..12).forEach { month -> + val period = Periods.of(shownYear.intValue, month) + val name = DateFormatter.monthYear(period, language) + FilterChip( + selected = period == selected, + onClick = { actions.onMonth(which, period) }, + enabled = period >= minimum, + label = { Text(DateFormatter.axisMonth(month, language)) }, + modifier = Modifier.weight(1f).clearAndSetSemantics { contentDescription = name; role = Role.RadioButton }, + ) + } + } + if (which == MonthField.Last) { + FilterChip(selected = state.form.end == null, onClick = { actions.onMonth(which, null) }, label = { Text(stringResource(R.string.settings_recurring_no_end)) }) + } + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + KvittButton(stringResource(DesignR.string.common_cancel), actions.onDismissDialog, kind = ButtonKind.Text) + } + } + } +} + +private const val YEARS_AHEAD = 3 +private const val MONTH_COLUMNS = 4 + +@Composable +private fun descriptionError(error: DescriptionError): String = when (error) { + DescriptionError.Required -> stringResource(DesignR.string.error_description_required) + DescriptionError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, DESCRIPTION_MAX, DESCRIPTION_MAX) + DescriptionError.Refused -> stringResource(DesignR.string.error_validation) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt new file mode 100644 index 0000000..c284e3e --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt @@ -0,0 +1,279 @@ +package se.hagstedt.kvitt.feature.settings.recurring + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import se.hagstedt.kvitt.design.component.AccountChoice +import se.hagstedt.kvitt.design.component.RatioField +import se.hagstedt.kvitt.design.component.quickRowCategories +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.feature.settings.common.UiMessage +import se.hagstedt.kvitt.feature.settings.common.failureCode +import se.hagstedt.kvitt.feature.settings.common.failureMessage +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountSummary +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.ExpenseEditorOptions +import se.hagstedt.kvitt.model.ExpenseRepository +import se.hagstedt.kvitt.model.HomeRepository +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.RecurringRepository +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.UserId +import javax.inject.Inject + +/** The S-19 editor (design-spec §4 S-19): the form with everything the screen needs resolved. */ +data class RecurringEditorState( + val options: ExpenseEditorOptions, + val original: RecurringTemplate?, + val form: RecurringForm, + val account: Account?, + val accountColorIndex: Int, + val choices: List, + val category: Category?, + val quickRow: List, + val shares: List?, + val errors: RecurringErrors, + val valid: Boolean, + val online: Boolean, + val busy: Boolean, + val sheet: EditorSheet?, + val dialog: EditorDialog?, + val message: UiMessage?, + val done: Boolean, +) { + val isNew: Boolean get() = original == null + val startPeriod: String get() = form.startPeriod(options.today) + val earliestStart: String get() = Periods.earliestStart(options.today) + val changed: Boolean get() = original == null || form.template(original, options.me.id, options.partner.id, options.today).copy(nextOccurrence = original.nextOccurrence) != original.normalised(options.today) + val canSave: Boolean get() = online && !busy && changed && valid && !errors.any + + /** The account's default ratio for "Kontots standard 60/40", viewer first. */ + val viewerDefaultPercent: Int get() = (account?.defaultShares?.get(options.me.id) ?: BP_TOTAL / 2) / 100 + val dayClampHint: Boolean get() = (form.dayOfMonth ?: 0) >= 29 + val resumeHint: Boolean get() = original?.paused == true && !form.paused +} + +/** The template as the form would rebuild it (draft date normalised), to tell whether anything changed. */ +private fun RecurringTemplate.normalised(today: String) = copy(draft = draft.copy(date = today, description = draft.description.trim(), notes = draft.notes.trim())) + +enum class EditorSheet { Category, Account } + +sealed interface EditorDialog { + data object Discard : EditorDialog + data object Delete : EditorDialog + data class Month(val which: MonthField) : EditorDialog +} + +enum class MonthField { First, Last } + +@HiltViewModel +class RecurringEditorViewModel @Inject constructor( + savedState: SavedStateHandle, + private val expenses: ExpenseRepository, + home: HomeRepository, + private val recurring: RecurringRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + /** Navigation argument: the template to edit, absent for a new one. */ + private val id: TemplateId? = savedState.get(ARG_TEMPLATE_ID)?.let { TemplateId(it) } + + private val local = MutableStateFlow(Local()) + + private val sources = combine(expenses.editorOptions(), home.home(), recurring.all(), connectivity.online) { o, h, t, online -> Sources(o, h.active + h.archived, t, online) } + + val state: StateFlow = combine(sources, local) { src, l -> + val original = id?.let { i -> src.templates.firstOrNull { it.id == i } } + if (id != null && original == null) return@combine null + val options = src.options + val form = l.form ?: if (original != null) RecurringForm.of(original, options.me.id) else RecurringForm.new(options) + val full = validate(form, options.today, original) + val ordered = src.summaries.map { it.account } + val account = ordered.firstOrNull { it.id == form.accountId } + val category = options.categories.firstOrNull { it.id == form.categoryId } + val selected = form.categoryId + val frequent = form.accountId?.let { options.frequentCategoryIds[it] }.orEmpty() + val draft = form.draft(options.me.id, options.partner.id, options.today) + RecurringEditorState( + options = options, + original = original, + form = form, + account = account, + accountColorIndex = ordered.indexOfFirst { it.id == form.accountId }.coerceAtLeast(0), + choices = src.summaries.mapIndexed { index, s -> AccountChoice(s, index) }.filter { !it.summary.account.archived }, + category = category, + quickRow = quickRowCategories(options.categories, frequent, selected), + shares = if (draft.amountOre != null && draft.accountId != null) expenses.preview(draft) else null, + errors = l.serverErrors ?: if (l.triedSave) full else full.copy(amount = null, description = null, day = false), + valid = !full.any, + online = src.online, + busy = l.busy, + sheet = l.sheet, + dialog = l.dialog, + message = l.message, + done = l.done, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + + private fun edit(change: (RecurringForm) -> RecurringForm) = local.update { l -> + val base = l.form ?: state.value?.form ?: return@update l + l.copy(form = change(base), serverErrors = null) + } + + fun onKey(key: AmountKey) = edit { it.press(key) } + + fun focus(focus: EditorFocus) = edit { it.copy(focus = focus) } + + fun onDescription(text: String) = edit { it.copy(description = text) } + + fun onNotes(text: String) = edit { it.copy(notes = text) } + + fun openNotes() = edit { it.copy(notesOpen = true, focus = EditorFocus.None) } + + /** A category picked in the quick row or the sheet ("picked by hand", CS-2). */ + fun onCategory(id: CategoryId) { + edit { it.copy(categoryId = id, categoryPicked = true) } + local.update { it.copy(sheet = null) } + } + + /** A new account; the AC-8.6 default category follows unless one was picked by hand. */ + fun onAccount(id: AccountId) { + val options = state.value?.options ?: return + edit { f -> f.copy(accountId = id, categoryId = if (f.categoryPicked) f.categoryId else options.defaultCategoryFor[id] ?: f.categoryId) } + local.update { it.copy(sheet = null) } + } + + /** AC-8.3: one tap swaps the payer. */ + fun togglePayer() { + val s = state.value ?: return + edit { f -> f.copy(payer = if ((f.payer ?: s.options.me.id) == s.options.me.id) s.options.partner.id else s.options.me.id) } + } + + fun toggleSplit() = edit { it.copy(splitOpen = !it.splitOpen, focus = EditorFocus.None) } + + fun onMode(mode: SplitMode) = edit { it.copy(mode = mode, focus = if (mode == SplitMode.EXACT) EditorFocus.Exact else EditorFocus.None) } + + /** Slider and presets set the viewer's percent. */ + fun onViewerPercent(percent: Int) = edit { it.copy(ratioTyped = RatioField.Viewer, ratioInput = AmountInput(percent.coerceIn(0, 100).toString())) } + + fun onRatioFocus(field: RatioField) = focus(if (field == RatioField.Viewer) EditorFocus.RatioViewer else EditorFocus.RatioPartner) + + /** AC-8.7: tapping the derived field makes that person the typed one. */ + fun onExactTyped(user: UserId) = edit { f -> + val derived = (f.amount.ore ?: 0L) - (f.exactInput.ore ?: 0L) + f.copy(exactTyped = user, exactInput = AmountInput.of(derived.coerceAtLeast(0L)), focus = EditorFocus.Exact) + } + + fun onFullOn(user: UserId) = edit { it.copy(fullOn = user) } + + fun onDay(text: String) = edit { it.withDay(text) } + + fun onPaused(paused: Boolean) = edit { it.copy(paused = paused) } + + fun openSheet(sheet: EditorSheet) { + focus(EditorFocus.None) + local.update { it.copy(sheet = sheet) } + } + + fun closeSheet() = local.update { it.copy(sheet = null) } + + fun openMonth(which: MonthField) { + focus(EditorFocus.None) + local.update { it.copy(dialog = EditorDialog.Month(which)) } + } + + /** A month from the picker; null on the last month means "Ingen slutmånad". */ + fun onMonth(which: MonthField, period: String?) { + edit { f -> if (which == MonthField.First) f.copy(start = period ?: f.start) else f.copy(end = period) } + local.update { it.copy(dialog = null) } + } + + fun dismissDialog() = local.update { it.copy(dialog = null) } + + /** Close: with changes the discard dialog first (design-spec S-19). */ + fun close() { + val s = state.value + if (s != null && s.changed && !(s.isNew && s.form == RecurringForm.new(s.options))) { + local.update { it.copy(dialog = EditorDialog.Discard) } + } else { + local.update { it.copy(done = true) } + } + } + + fun discard() = local.update { it.copy(dialog = null, done = true) } + + fun askDelete() = local.update { it.copy(dialog = EditorDialog.Delete) } + + /** "Spara" (online): `create` / `update`; success → back; a refused field is marked inline, network → snackbar. */ + fun save() { + local.update { it.copy(triedSave = true) } + val s = state.value ?: return + if (!s.canSave) return + val template = s.form.template(s.original, s.options.me.id, s.options.partner.id, s.options.today) + focus(EditorFocus.None) + local.update { it.copy(busy = true) } + viewModelScope.launch { + val result: OnlineResult<*> = if (s.original == null) recurring.create(template) else recurring.update(template) + local.update { it.copy(busy = false) } + when { + result is OnlineResult.Ok -> local.update { it.copy(done = true) } + result is OnlineResult.Failed && result.code == "validation_failed" && serverErrors(result.field) != null -> + local.update { it.copy(serverErrors = serverErrors(result.field)) } + else -> fail(result, retry = true) + } + } + } + + /** Confirmed "Ta bort": created expenses stay (design-spec S-19). */ + fun delete() { + val original = state.value?.original ?: return + local.update { it.copy(dialog = null, busy = true) } + viewModelScope.launch { + val result = recurring.delete(original.id) + local.update { it.copy(busy = false) } + if (result is OnlineResult.Ok) local.update { it.copy(done = true) } else fail(result, retry = false) + } + } + + fun onRetry() = save() + + fun onMessageShown() = local.update { it.copy(message = null) } + + private fun fail(result: OnlineResult<*>, retry: Boolean) { + val code = result.failureCode() ?: return + local.update { it.copy(message = failureMessage(code, canRetry = retry)) } + } + + private data class Sources(val options: ExpenseEditorOptions, val summaries: List, val templates: List, val online: Boolean) + + private data class Local( + val form: RecurringForm? = null, + val triedSave: Boolean = false, + val serverErrors: RecurringErrors? = null, + val busy: Boolean = false, + val sheet: EditorSheet? = null, + val dialog: EditorDialog? = null, + val message: UiMessage? = null, + val done: Boolean = false, + ) + + companion object { + /** The `SavedStateHandle` key of the template id (a string; absent for a new template). */ + const val ARG_TEMPLATE_ID = "templateId" + } +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt new file mode 100644 index 0000000..99bf8bf --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt @@ -0,0 +1,204 @@ +package se.hagstedt.kvitt.feature.settings.recurring + +import se.hagstedt.kvitt.design.component.RatioField +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ExpenseDraft +import se.hagstedt.kvitt.model.ExpenseEditorOptions +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.model.UserId + +/** Which keypad field has focus (design-spec §3.7: the keypad drives the amount, the exact share and the percents). */ +enum class EditorFocus { None, Amount, RatioViewer, RatioPartner, Exact } + +/** §9.6: description 1 to 100, notes 0 to 1000 code points. */ +const val DESCRIPTION_MAX = 100 +const val BP_TOTAL = 10_000 + +/** + * The S-19 editor's input, independent of the repositories: the expense fields of S-7 without the date (D-019) plus + * the schedule of FR-9. Pure, so the editor's rules are unit-tested without Android. + * + * Ratio mode keeps the typed percent field ([ratioTyped], [ratioInput] in hundredths of a percent = basis points); + * the other field is 100 minus it. Exact mode keeps the typed person's amount ([exactTyped], [exactInput]). + * [start] null means the AC-9.8 default for the current [day]. + */ +data class RecurringForm( + val amount: AmountInput = AmountInput(), + val description: String = "", + val accountId: AccountId? = null, + val categoryId: CategoryId? = null, + val categoryPicked: Boolean = false, + val payer: UserId? = null, + val mode: SplitMode = SplitMode.DEFAULT, + val ratioTyped: RatioField = RatioField.Viewer, + val ratioInput: AmountInput = AmountInput("50"), + val exactTyped: UserId? = null, + val exactInput: AmountInput = AmountInput(), + val fullOn: UserId? = null, + val notes: String = "", + val notesOpen: Boolean = false, + val day: String = "", + val start: String? = null, + val end: String? = null, + val paused: Boolean = false, + val focus: EditorFocus = EditorFocus.None, + val splitOpen: Boolean = false, +) { + val dayOfMonth: Int? get() = day.toIntOrNull()?.takeIf { it in 1..MAX_DAY } + + fun startPeriod(today: String): String = start ?: Periods.defaultStart(today, dayOfMonth) + + /** The viewer's basis points in ratio mode. */ + val viewerBp: Int get() { + val typed = (ratioInput.ore ?: 0L).toInt().coerceIn(0, BP_TOTAL) + return if (ratioTyped == RatioField.Viewer) typed else BP_TOTAL - typed + } + + /** The draft the server and `ExpenseRepository.preview` see; the date is today (a template has none). */ + fun draft(me: UserId, partner: UserId, today: String): ExpenseDraft { + val payerId = payer ?: me + val typed = exactTyped ?: me + val other = if (typed == me) partner else me + return ExpenseDraft( + id = null, + accountId = accountId, + categoryId = categoryId, + amountOre = amount.ore, + description = description.trim(), + notes = notes.trim(), + date = today, + payer = payerId, + mode = mode, + ratioBp = if (mode == SplitMode.RATIO) mapOf(me to viewerBp, partner to BP_TOTAL - viewerBp) else null, + exactOre = if (mode == SplitMode.EXACT) exactInput.ore?.let { typedOre -> mapOf(typed to typedOre, other to (amount.ore ?: 0L) - typedOre) } else null, + fullOn = if (mode == SplitMode.FULL) fullOn ?: me else null, + ) + } + + fun template(original: RecurringTemplate?, me: UserId, partner: UserId, today: String): RecurringTemplate = RecurringTemplate( + id = original?.id ?: TemplateId(""), + draft = draft(me, partner, today), + dayOfMonth = dayOfMonth ?: 0, + startPeriod = startPeriod(today), + endPeriod = end, + paused = paused, + nextOccurrence = original?.nextOccurrence, + ) + + /** One keypad key on the focused field; a rejected key leaves the form unchanged. */ + fun press(key: AmountKey): RecurringForm = when (focus) { + EditorFocus.Amount -> amount.press(key)?.let { copy(amount = it) } ?: this + EditorFocus.RatioViewer, EditorFocus.RatioPartner -> { + val field = if (focus == EditorFocus.RatioViewer) RatioField.Viewer else RatioField.Partner + val base = if (ratioTyped == field) ratioInput else AmountInput() + base.press(key, AmountInput.MAX_PERCENT_HUNDREDTHS)?.let { copy(ratioTyped = field, ratioInput = it) } ?: this + } + EditorFocus.Exact -> exactInput.press(key)?.let { copy(exactInput = it) } ?: this + EditorFocus.None -> this + } + + /** Day field input: digits only, at most two, at most 31. */ + fun withDay(text: String): RecurringForm { + val digits = text.filter { it.isDigit() }.take(2) + if (digits.isNotEmpty() && digits.toInt() > MAX_DAY) return this + return copy(day = digits) + } + + companion object { + const val MAX_DAY = 31 + + /** A new template: AC-8.6-style defaults (no category suggestion in S-19, D-059 / design-spec §6 point 3). */ + fun new(options: ExpenseEditorOptions): RecurringForm { + val account = options.defaultAccountId + return RecurringForm( + accountId = account, + categoryId = account?.let { options.defaultCategoryFor[it] }, + payer = options.me.id, + exactTyped = options.me.id, + fullOn = options.me.id, + focus = EditorFocus.Amount, + day = options.today.substring(8, 10).trimStart('0'), + ) + } + + /** An existing template opened for editing. */ + fun of(t: RecurringTemplate, me: UserId): RecurringForm { + val d = t.draft + val viewerBp = d.ratioBp?.get(me) + val typed = d.exactOre?.keys?.firstOrNull { it == me } ?: d.exactOre?.keys?.firstOrNull() ?: me + return RecurringForm( + amount = d.amountOre?.let { AmountInput.of(it) } ?: AmountInput(), + description = d.description, + accountId = d.accountId, + categoryId = d.categoryId, + categoryPicked = true, + payer = d.payer, + mode = d.mode, + ratioInput = AmountInput.of((viewerBp ?: BP_TOTAL / 2).toLong()), + exactTyped = typed, + exactInput = d.exactOre?.get(typed)?.let { AmountInput.of(it) } ?: AmountInput(), + fullOn = d.fullOn ?: me, + notes = d.notes, + notesOpen = d.notes.isNotEmpty(), + day = t.dayOfMonth.toString(), + start = t.startPeriod, + end = t.endPeriod, + paused = t.paused, + ) + } + } +} + +/** Field errors of the editor, local or from a refused save (`validation_failed` with a field, §14.3.2). */ +data class RecurringErrors( + val amount: AmountError? = null, + val description: DescriptionError? = null, + val day: Boolean = false, + val start: StartError? = null, + val end: Boolean = false, + val split: Boolean = false, +) { + val any: Boolean get() = amount != null || description != null || day || start != null || end || split +} + +enum class AmountError { Required, Refused } + +enum class DescriptionError { Required, TooLong, Refused } + +enum class StartError { TooEarly, Refused } + +/** The local checks of §9.6 and AC-9.8. [original] set: an unchanged first month is never "too early". */ +fun validate(form: RecurringForm, today: String, original: RecurringTemplate?): RecurringErrors { + val description = form.description.trim() + val length = description.codePointCount(0, description.length) + val start = form.startPeriod(today) + val exactOver = form.mode == SplitMode.EXACT && (form.exactInput.ore ?: 0L) > (form.amount.ore ?: 0L) + return RecurringErrors( + amount = if (form.amount.ore == null || form.amount.ore == 0L) AmountError.Required else null, + description = when { + length == 0 -> DescriptionError.Required + length > DESCRIPTION_MAX -> DescriptionError.TooLong + else -> null + }, + day = form.dayOfMonth == null, + start = if (start != original?.startPeriod && start < Periods.earliestStart(today)) StartError.TooEarly else null, + end = form.end?.let { it < start } == true, + split = exactOver || (form.mode == SplitMode.EXACT && form.exactInput.ore == null), + ) +} + +/** The error a refused save shows at its field (`validation_failed`, field per §14.3.2); null: no field to mark. */ +fun serverErrors(field: String?): RecurringErrors? = when (field) { + "amount_ore", "amountOre" -> RecurringErrors(amount = AmountError.Refused) + "description" -> RecurringErrors(description = DescriptionError.Refused) + "day_of_month" -> RecurringErrors(day = true) + "start_period" -> RecurringErrors(start = StartError.Refused) + "end_period" -> RecurringErrors(end = true) + "splits", "split" -> RecurringErrors(split = true) + else -> null +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt new file mode 100644 index 0000000..26c77d7 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListScreen.kt @@ -0,0 +1,174 @@ +package se.hagstedt.kvitt.feature.settings.recurring + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.style.TextOverflow +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AddExpenseFab +import se.hagstedt.kvitt.design.component.AmountStyle +import se.hagstedt.kvitt.design.component.AmountTone +import se.hagstedt.kvitt.design.component.BadgeKind +import se.hagstedt.kvitt.design.component.CategoryTile +import se.hagstedt.kvitt.design.component.EmptyState +import se.hagstedt.kvitt.design.component.KvittBadge +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.LocalKvittClock +import se.hagstedt.kvitt.design.component.LocalKvittHousehold +import se.hagstedt.kvitt.design.component.MoneyText +import se.hagstedt.kvitt.design.component.ProvideHousehold +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SegmentedItemScope +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.listDate +import se.hagstedt.kvitt.design.component.spokenMoney +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold +import se.hagstedt.kvitt.feature.settings.common.OfflineBanner +import se.hagstedt.kvitt.model.TemplateId +import se.hagstedt.kvitt.core.design.R as DesignR + +/** S-19 Recurring expenses list. */ +@Composable +fun RecurringListScreen( + onBack: () -> Unit, + onOpenTemplate: (TemplateId) -> Unit, + onNewTemplate: () -> Unit, + modifier: Modifier = Modifier, + viewModel: RecurringListViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + RecurringListContent(state, RecurringListActions(onBack, onOpenTemplate, onNewTemplate), modifier) +} + +class RecurringListActions(val onBack: () -> Unit = {}, val onOpen: (TemplateId) -> Unit = {}, val onNew: () -> Unit = {}) + +/** Stateless S-19 list. */ +@Composable +fun RecurringListContent(state: RecurringListState?, actions: RecurringListActions, modifier: Modifier = Modifier) { + val empty = state != null && state.rows.isEmpty() + SettingsScaffold( + modifier.fillMaxSize(), + topBar = { KvittTopAppBar(stringResource(R.string.settings_recurring_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) }, + floatingActionButton = { + if (state != null && !empty) AddExpenseFab(actions.onNew, text = stringResource(R.string.settings_recurring_new), enabled = state.online) + }, + ) { padding -> + if (state == null) return@SettingsScaffold + ProvideHousehold(state.me, state.partner) { + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + if (!state.online) OfflineBanner(stringResource(R.string.settings_recurring_offline_banner)) + if (empty) { + EmptyState( + stringResource(R.string.settings_recurring_empty_title), + stringResource(R.string.settings_recurring_empty_body), + action = if (state.online) stringResource(R.string.settings_recurring_empty_action) else null, + actionIcon = KvittIcon.Add, + onAction = actions.onNew, + ) + } else { + SegmentedGroup(state.rows.size) { item -> + val row = state.rows[item.index] + TemplateRow(item, row, state.online) { actions.onOpen(row.template.id) } + } + Spacer(Modifier.height(KvittTheme.spacing.fabClearance)) + } + } + } + } +} + +/** + * One template (design-spec S-19): tile 40 (muted when paused), description over "Huset · Anna betalar" over the + * schedule lines or the "Pausad" badge, amount right (70 % when paused). One TalkBack node. + */ +@Composable +private fun TemplateRow(item: SegmentedItemScope, row: RecurringRow, online: Boolean, onClick: () -> Unit) { + val t = row.template + val paused = t.paused + val payer = row.payer + val accountName = row.account?.name.orEmpty() + val who = when { + payer == null -> accountName + LocalKvittHousehold.current.isViewer(payer.id) -> stringResource(R.string.settings_recurring_row_you_pay, accountName) + else -> stringResource(R.string.settings_recurring_row_partner_pays, accountName, payer.displayName) + } + val every = stringResource(R.string.settings_recurring_every_month_on, dayOrdinal(t.dayOfMonth)) + val next = t.nextOccurrence?.let { stringResource(R.string.settings_recurring_next, listDate(it)) } + val pausedWord = stringResource(R.string.settings_recurring_paused) + val comma = stringResource(DesignR.string.common_separator_comma) + val spokenNext = t.nextOccurrence?.let { + stringResource(R.string.settings_recurring_next, DateFormatter.spokenDate(DateFormatter.parseDate(it), LocalKvittClock.current.today, kvittLanguage())) + } + val spoken = listOfNotNull(t.draft.description, spokenMoney(t.draft.amountOre ?: 0L), who, every, if (paused) pausedWord else spokenNext).joinToString(comma) + Row( + Modifier + .fillMaxWidth() + .clip(item.shape) + .background(item.fill) + .clickable(enabled = online, onClick = onClick) + .clearAndSetSemantics { contentDescription = spoken; if (online) role = Role.Button } + .heightIn(min = KvittTheme.spacing.ledgerRowMin) + .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittDimens.dialogGap), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + row.category?.let { CategoryTile(it, KvittDimens.tileLedger, archived = paused || it.archived) } ?: Spacer(Modifier.width(KvittDimens.tileLedger)) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + Text(t.draft.description, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(who, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (paused) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittBadge(pausedWord, kind = BadgeKind.Lantern, icon = KvittIcon.Pause) + Text(every, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + Text(every, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (next != null) Text(next, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + MoneyText(t.draft.amountOre ?: 0L, AmountStyle.Small, AmountTone.Neutral, Modifier.alpha(if (paused) PAUSED_ALPHA else 1f)) + } +} + +/** Design-spec S-19: a paused template's amount at 70 % opacity. */ +private const val PAUSED_ALPHA = 0.7f + + diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListViewModel.kt new file mode 100644 index 0000000..4d8ff09 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringListViewModel.kt @@ -0,0 +1,53 @@ +package se.hagstedt.kvitt.feature.settings.recurring + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountRepository +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryRepository +import se.hagstedt.kvitt.model.ConnectivityRepository +import se.hagstedt.kvitt.model.ProfileRepository +import se.hagstedt.kvitt.model.RecurringRepository +import se.hagstedt.kvitt.model.RecurringTemplate +import se.hagstedt.kvitt.model.SessionRepository +import se.hagstedt.kvitt.model.SessionState +import se.hagstedt.kvitt.model.User +import javax.inject.Inject + +/** One S-19 row (AC-9.7): the template with its account, category and payer resolved. */ +data class RecurringRow(val template: RecurringTemplate, val account: Account?, val category: Category?, val payer: User?) + +/** S-19 list (design-spec §4 S-19). */ +data class RecurringListState(val me: User, val partner: User?, val rows: List, val online: Boolean) + +@HiltViewModel +class RecurringListViewModel @Inject constructor( + recurring: RecurringRepository, + accounts: AccountRepository, + categories: CategoryRepository, + session: SessionRepository, + profile: ProfileRepository, + connectivity: ConnectivityRepository, +) : ViewModel() { + private val people = combine(session.session, profile.household()) { s, h -> s to h } + + val state: StateFlow = combine(recurring.all(), accounts.all(), categories.all(), people, connectivity.online) { templates, acc, cats, (s, household), online -> + val paired = s as? SessionState.Paired ?: return@combine null + val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me + val users = household.ifEmpty { listOfNotNull(paired.me, paired.partner) } + RecurringListState( + me = me, + partner = household.firstOrNull { it.id != me.id } ?: paired.partner, + rows = templates.map { t -> + RecurringRow(t, acc.firstOrNull { it.id == t.draft.accountId }, cats.firstOrNull { it.id == t.draft.categoryId }, users.firstOrNull { it.id == t.draft.payer }) + }, + online = online, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, null) +} diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringWords.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringWords.kt new file mode 100644 index 0000000..78b1e04 --- /dev/null +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringWords.kt @@ -0,0 +1,12 @@ +package se.hagstedt.kvitt.feature.settings.recurring + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import se.hagstedt.kvitt.feature.settings.R + +/** Days whose Swedish ordinal ends in ":a" (1:a, 2:a, 21:a, 22:a, 31:a); every other day takes ":e". */ +fun ordinalTakesA(day: Int): Boolean = day % 10 in 1..2 && day % 100 !in 11..12 + +/** "25:e" / "1:a" in Swedish, "25" in English (`settings_recurring_day_ordinal_*`). */ +@Composable +fun dayOrdinal(day: Int): String = stringResource(if (ordinalTakesA(day)) R.string.settings_recurring_day_ordinal_a else R.string.settings_recurring_day_ordinal_e, day) diff --git a/android/feature/settings/src/main/res/values-sv/strings.xml b/android/feature/settings/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..90045f3 --- /dev/null +++ b/android/feature/settings/src/main/res/values-sv/strings.xml @@ -0,0 +1,194 @@ + + + + Kräver anslutning. Profilen kan bara ändras när telefonen är online. + Inställningar + + Du · Administratör + + Du + + Admin + Profil + + Namn och bild + + Så här ser %1$s dig i Kvitt. + Kategorier + + + %d aktiv + %d aktiva + + Återkommande utgifter + + + %d aktiv + %d aktiva + + + + %d pausad + %d pausade + + Enheter + + + %d enhet + %d enheter + + Para ihop en enhet + Synkstatus + Om Kvitt + + Version %1$s + %d väntar + Uppdatering finns + Kategorier + Ny kategori + Arkiverade (%d) + Kräver anslutning för att ändra + + Flytta upp + + Flytta ned + + Flytta %1$s + Ny kategori + Ändra kategori + + Arkiverad + + Återställ namn + + Standardnamn: %1$s + Ikon + Färg + Arkivera + Återställ från arkiv + Ta bort kategori + + Ta bort kategorin %1$s? + + Den försvinner från listan för er båda. Det går inte att ångra. + + Standardkategorier kan inte tas bort, bara arkiveras + + Kan inte tas bort: standardkategori + + Kategorin används av utgifter eller återkommande utgifter. Arkivera den i stället. + + Kategorin används av utgifter eller återkommande utgifter och kan inte tas bort. + Du har den senaste versionen + Sök efter uppdatering + Återkommande utgifter + + Ny återkommande + + Varje månad den %1$s + + %1$d:e + + %1$d:a + + Nästa %1$s + + %1$s · Du betalar + + %1$s · %2$s betalar + Pausad + Inga återkommande utgifter + Hyra, el och streaming kan läggas in automatiskt varje månad. + Ny återkommande utgift + Ny återkommande utgift + + Återkommande utgift + + Schema + Dag i månaden + + I kortare månader används sista dagen + Första månad + Sista månad (valfritt) + + Ingen slutmånad + Pausad + + Skapas kl. 06:00 den %1$s varje månad + + Månader som passerat under pausen skapas inte i efterhand. + + Ändringen gäller kommande utgifter. + Ta bort %1$s? + Utgifter som redan skapats finns kvar. + Kräver anslutning. Återkommande utgifter kan bara ändras när telefonen är online. + Sista månaden kan inte vara före den första + Högst 12 månader bakåt + Enheter + Parad %1$s + Senast sedd %1$s + Kvitt %1$s + + Den här enheten + + Ta bort åtkomst + + Du kan inte ta bort åtkomsten för den telefon du använder just nu. + + Ta bort åtkomst för %1$s? + + Telefonen loggas ut och allt Kvitt-data på den raderas. Du kan para ihop den igen med en ny inbjudningskod. + + %1$ss telefon loggas ut och allt Kvitt-data på den raderas. %1$s kan para ihop den igen med en ny inbjudningskod. + Kräver anslutning för att visa enheter + Hämtar enheter… + Para ihop en enhet + + Ny enhet för %1$s + + Inbjudan till din partner + + Skapa en engångskod för att para ihop en telefon till. Koden gäller i 72 timmar. + För mig + För %1$s + Gäller till %1$s + Dela + Kopiera kod + Koden kopierades + Öppna länken på den nya telefonen, eller skriv in koden i Kvitt. Koden fungerar en gång. + Gå med i vårt hushåll i Kvitt: %1$s Koden är %2$s. + Väntande inbjudningar + Inga väntande inbjudningar + + Kräver anslutning. Inbjudningar kan bara skapas och visas när telefonen är online. + Partner + + Enhet · %1$s + + Skapad %1$s + Koden visas bara när den skapas + + Avbryt inbjudan + Avbryta inbjudan? + Om Kvitt + + Kvitt %1$s (%2$d) + Server + + Serverversion + Okänd + Licenser för öppen källkod + + %1$s arkiverades + + %1$s återställdes + + Inga + + utfälld + + hopfälld + + Partner saknas + diff --git a/android/feature/settings/src/main/res/values/strings.xml b/android/feature/settings/src/main/res/values/strings.xml new file mode 100644 index 0000000..8170779 --- /dev/null +++ b/android/feature/settings/src/main/res/values/strings.xml @@ -0,0 +1,194 @@ + + + + Needs connection. Your profile can only be changed online. + Settings + + You · Admin + + You + + Admin + Profile + + Name and picture + + This is how %1$s sees you in Kvitt. + Categories + + + %d active + %d active + + Recurring expenses + + + %d active + %d active + + + + %d paused + %d paused + + Devices + + + %d device + %d devices + + Pair a device + Sync status + About Kvitt + + Version %1$s + %d waiting + Update available + Categories + New category + Archived (%d) + Needs connection to change + + Move up + + Move down + + Move %1$s + New category + Edit category + + Archived + + Reset name + + Default name: %1$s + Icon + Colour + Archive + Restore from archive + Delete category + + Delete the category %1$s? + + It disappears from the list for both of you. This cannot be undone. + + Default categories cannot be deleted, only archived + + Cannot be deleted: default category + + The category is used by expenses or recurring expenses. Archive it instead. + + The category is used by expenses or recurring expenses and cannot be deleted. + You have the latest version + Check for update + Recurring expenses + + New recurring + + Every month on day %1$s + + %1$d + + %1$d + + Next %1$s + + %1$s · You pay + + %1$s · %2$s pays + Paused + No recurring expenses + Rent, electricity and streaming can be added automatically every month. + New recurring expense + New recurring expense + + Recurring expense + + Schedule + Day of month + + In shorter months the last day is used + First month + Last month (optional) + + No last month + Paused + + Created at 06:00 on day %1$s every month + + Months that passed while paused are not created afterwards. + + The change applies to upcoming expenses. + Delete %1$s? + Expenses already created stay. + Needs connection. Recurring expenses can only be changed online. + The last month cannot be before the first + At most 12 months back + Devices + Paired %1$s + Last seen %1$s + Kvitt %1$s + + This device + + Remove access + + You cannot remove access for the phone you are using right now. + + Remove access for %1$s? + + The phone is signed out and all Kvitt data on it is deleted. You can pair it again with a new invite code. + + %1$s\'s phone is signed out and all Kvitt data on it is deleted. %1$s can pair it again with a new invite code. + Needs connection to show devices + Loading devices… + Pair a device + + New device for %1$s + + Invite for your partner + + Create a one-time code to pair another phone. The code is valid for 72 hours. + For me + For %1$s + Valid until %1$s + Share + Copy code + Code copied + Open the link on the new phone, or enter the code in Kvitt. The code works once. + Join our household in Kvitt: %1$s The code is %2$s. + Pending invites + No pending invites + + Needs connection. Invites can only be created and shown online. + Partner + + Device · %1$s + + Created %1$s + The code is only shown when it is created + + Cancel invite + Cancel the invite? + About Kvitt + + Kvitt %1$s (%2$d) + Server + + Server version + Unknown + Open-source licences + + %1$s was archived + + %1$s was restored + + None + + expanded + + collapsed + + Partner has not joined + diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt new file mode 100644 index 0000000..06b8832 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/MainDispatcherRule.kt @@ -0,0 +1,17 @@ +package se.hagstedt.kvitt.feature.settings.logic + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** `viewModelScope` runs on an unconfined test dispatcher, so every ViewModel call completes before it returns. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MainDispatcherRule : TestWatcher() { + override fun starting(description: Description) = Dispatchers.setMain(UnconfinedTestDispatcher()) + + override fun finished(description: Description) = Dispatchers.resetMain() +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/PureRulesTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/PureRulesTest.kt new file mode 100644 index 0000000..b455068 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/PureRulesTest.kt @@ -0,0 +1,127 @@ +package se.hagstedt.kvitt.feature.settings.logic + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import se.hagstedt.kvitt.design.component.RatioField +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.fakes.settings.SettingsScenarios +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.categories.CategoryNameError +import se.hagstedt.kvitt.feature.settings.categories.DeleteBlock +import se.hagstedt.kvitt.feature.settings.categories.categoryNameError +import se.hagstedt.kvitt.feature.settings.categories.deleteBlock +import se.hagstedt.kvitt.feature.settings.categories.deleteBlockOf +import se.hagstedt.kvitt.feature.settings.categories.moveId +import se.hagstedt.kvitt.feature.settings.common.failureMessage +import se.hagstedt.kvitt.feature.settings.common.failureText +import se.hagstedt.kvitt.feature.settings.devices.canRevoke +import se.hagstedt.kvitt.feature.settings.recurring.AmountError +import se.hagstedt.kvitt.feature.settings.recurring.EditorFocus +import se.hagstedt.kvitt.feature.settings.recurring.Periods +import se.hagstedt.kvitt.feature.settings.recurring.RecurringForm +import se.hagstedt.kvitt.feature.settings.recurring.StartError +import se.hagstedt.kvitt.feature.settings.recurring.ordinalTakesA +import se.hagstedt.kvitt.feature.settings.recurring.serverErrors +import se.hagstedt.kvitt.feature.settings.recurring.validate +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.core.design.R as DesignR + +/** Pure rules of the settings screens. */ +class PureRulesTest { + @Test + fun periods() { + assertEquals("2027-01", Periods.plus("2026-12", 1)) + assertEquals("2025-10", Periods.plus("2026-10", -12)) + assertEquals("2025-10", Periods.earliestStart("2026-10-17")) + assertEquals("2026-10", Periods.defaultStart("2026-10-17", 17)) // AC-9.8: today or later → this month + assertEquals("2026-11", Periods.defaultStart("2026-10-17", 16)) + assertEquals("2026-10", Periods.defaultStart("2026-10-17", null)) + } + + @Test + fun swedishOrdinals() { + assertTrue(listOf(1, 2, 21, 22, 31).all { ordinalTakesA(it) }) + assertFalse(listOf(3, 11, 12, 20, 25, 30).any { ordinalTakesA(it) }) + } + + @Test + fun dayInputAndKeypad() { + val form = RecurringForm() + assertEquals("25", form.withDay("2a5").day) + assertEquals("", form.withDay("32").day) + val amount = RecurringForm(focus = EditorFocus.Amount).press(AmountKey.Digit(4)).press(AmountKey.Decimal).press(AmountKey.Digit(5)) + assertEquals(450L, amount.amount.ore) + val ratio = RecurringForm(focus = EditorFocus.RatioPartner).press(AmountKey.Digit(3)).press(AmountKey.Digit(0)) + assertEquals(RatioField.Partner, ratio.ratioTyped) + assertEquals(7_000, ratio.viewerBp) + assertEquals("above 100 % is refused", ratio, ratio.press(AmountKey.Digit(1))) + } + + @Test + fun drafts() { + val me = SettingsScenarios.jacob.id + val partner = SettingsScenarios.anna.id + val exact = RecurringForm(amount = AmountInput("100"), mode = SplitMode.EXACT, exactTyped = partner, exactInput = AmountInput("30")) + assertEquals(mapOf(partner to 3_000L, me to 7_000L), exact.draft(me, partner, "2026-10-17").exactOre) + val ratio = RecurringForm(mode = SplitMode.RATIO, ratioInput = AmountInput("60")) + assertEquals(mapOf(me to 6_000, partner to 4_000), ratio.draft(me, partner, "2026-10-17").ratioBp) + assertNull(RecurringForm().draft(me, partner, "2026-10-17").ratioBp) + } + + @Test + fun templateValidation() { + val today = "2026-10-17" + val empty = validate(RecurringForm(), today, null) + assertEquals(AmountError.Required, empty.amount) + assertTrue(empty.day) + val ok = RecurringForm(amount = AmountInput("10"), description = "Hyra", day = "25") + assertFalse(validate(ok, today, null).any) + assertEquals(StartError.TooEarly, validate(ok.copy(start = "2025-09"), today, null).start) + assertNull("an unchanged old start stays", validate(ok.copy(start = "2025-09"), today, SettingsScenarios.hyra.copy(startPeriod = "2025-09")).start) + assertTrue(validate(ok.copy(start = "2026-11", end = "2026-10"), today, null).end) + assertTrue(validate(ok.copy(mode = SplitMode.EXACT, exactInput = AmountInput("11")), today, null).split) + assertEquals(StartError.Refused, serverErrors("start_period")!!.start) + assertNull(serverErrors("account_id")) + } + + @Test + fun categoryRules() { + assertEquals(DeleteBlock.Default, deleteBlock(DefaultCategoryKey.OTHER, deletable = false, archived = false)) + assertEquals(DeleteBlock.InUse, deleteBlock(null, deletable = false, archived = false)) + assertEquals(DeleteBlock.InUseArchived, deleteBlock(null, deletable = false, archived = true)) + assertNull(deleteBlock(null, deletable = true, archived = true)) + assertEquals(DeleteBlock.Default, deleteBlockOf("category_is_default", archived = false)) + assertNull(deleteBlockOf("network", archived = false)) + assertNull(categoryNameError("", isDefault = true)) + assertEquals(CategoryNameError.Required, categoryNameError(" ", isDefault = false)) + assertEquals(CategoryNameError.TooLong, categoryNameError("é".repeat(41), isDefault = false)) + val ids = listOf("a", "b", "c").map { CategoryId(it) } + assertEquals(listOf("b", "c", "a").map { CategoryId(it) }, moveId(ids, ids[0], 9)) + } + + @Test + fun revokeRules() { + val devices = SettingsScenarios.loaded.devices + val jacob = SettingsScenarios.jacob + assertFalse("current", canRevoke(devices.first { it.current }, jacob)) + assertTrue(canRevoke(devices.first { it.id == SettingsScenarios.annaPhone }, jacob)) + assertFalse("member, partner's phone", canRevoke(devices.first { it.id == SettingsScenarios.jacobPhone }.copy(current = false), SettingsScenarios.anna.copy(role = Role.MEMBER))) + } + + @Test + fun errorWords() { + assertEquals(DesignR.string.common_could_not_reach_server, failureText("network")) + assertTrue(failureMessage("network").retry) + assertFalse(failureMessage("household_full").retry) + assertEquals(R.string.settings_devices_cannot_revoke_current, failureText("cannot_revoke_current_device")) + assertEquals(DesignR.string.profile_picture_invalid, failureText("image_invalid")) + assertEquals(DesignR.string.error_generic_invalid, failureText("something_new")) + } +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt new file mode 100644 index 0000000..eb1faa7 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/logic/StateHolderTest.kt @@ -0,0 +1,259 @@ +package se.hagstedt.kvitt.feature.settings.logic + +import androidx.lifecycle.SavedStateHandle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import se.hagstedt.kvitt.fakes.settings.SAMPLE_CODE +import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories +import se.hagstedt.kvitt.fakes.settings.SettingsScenarios +import se.hagstedt.kvitt.feature.settings.R +import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorViewModel +import se.hagstedt.kvitt.feature.settings.categories.CategoryListViewModel +import se.hagstedt.kvitt.feature.settings.categories.DeleteBlock +import se.hagstedt.kvitt.feature.settings.categories.EditorOutcome +import se.hagstedt.kvitt.feature.settings.devices.DeviceList +import se.hagstedt.kvitt.feature.settings.devices.DevicesViewModel +import se.hagstedt.kvitt.feature.settings.hub.InviteRow +import se.hagstedt.kvitt.feature.settings.hub.SettingsHubViewModel +import se.hagstedt.kvitt.feature.settings.invites.InvitesViewModel +import se.hagstedt.kvitt.feature.settings.profile.NameError +import se.hagstedt.kvitt.feature.settings.profile.ProfileViewModel +import se.hagstedt.kvitt.feature.settings.recurring.EditorFocus +import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel +import se.hagstedt.kvitt.feature.settings.recurring.StartError +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.core.design.R as DesignR + +/** The settings ViewModels against the fakes: input handling, enablement rules and error mapping (PLAN-M7f §5). */ +class StateHolderTest { + @get:Rule + val main = MainDispatcherRule() + + private fun hub(r: ScriptedSettingsRepositories) = + SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity) + + @Test + fun hubCountsAndRows() { + val admin = hub(ScriptedSettingsRepositories(SettingsScenarios.loaded)).state.value!! + assertEquals(15, admin.activeCategories) + assertEquals(3, admin.activeTemplates) + assertEquals(1, admin.pausedTemplates) + assertEquals(InviteRow.PairDevice, admin.inviteRow) + assertEquals(InviteRow.InvitePartner, hub(ScriptedSettingsRepositories(SettingsScenarios.partnerNotJoined)).state.value!!.inviteRow) + assertNull(hub(ScriptedSettingsRepositories(SettingsScenarios.member)).state.value!!.inviteRow) + assertTrue(hub(ScriptedSettingsRepositories(SettingsScenarios.updateAvailable)).state.value!!.updateAvailable) + } + + private fun profile(r: ScriptedSettingsRepositories) = ProfileViewModel(r.profile, r.fakes.session, r.fakes.connectivity) + + @Test + fun profileNameRulesAndSave() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + val vm = profile(r) + assertFalse("unchanged name", vm.state.value!!.canSave) + vm.onName(" ") + assertEquals(NameError.Required, vm.state.value!!.nameError) + vm.onName("x".repeat(41)) + assertEquals(NameError.TooLong(40), vm.state.value!!.nameError) + vm.onName("Jacob H") + assertTrue(vm.state.value!!.canSave) + vm.save() + assertTrue(vm.state.value!!.done) + assertEquals("Jacob H", r.fakes.store.current.me.displayName) + } + + @Test + fun profileRefusedNameIsInlineAndPictureErrorsAreSnackbars() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + val vm = profile(r) + vm.onName("Jacob H") + r.script.fail = OnlineResult.Failed("validation_failed", "display_name") + vm.save() + assertEquals(NameError.Refused, vm.state.value!!.nameError) + r.script.fail = OnlineResult.Failed("payload_too_large", null) + vm.onPicture(ByteArray(1), "image/jpeg") + assertEquals(DesignR.string.profile_picture_too_large, vm.state.value!!.message!!.text) + assertFalse(vm.state.value!!.uploading) + r.script.fail = OnlineResult.Failed("network", null) + vm.removePicture() + assertTrue(vm.state.value!!.message!!.retry) + } + + @Test + fun profileOfflineDisablesEverything() { + val s = profile(ScriptedSettingsRepositories(SettingsScenarios.offline)).state.value!! + assertFalse(s.pictureEnabled) + assertFalse(s.canSave) + } + + @Test + fun categoryReorderFailureRestoresTheOrder() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + val vm = CategoryListViewModel(r.categories, r.fakes.connectivity) + val before = vm.state.value!!.active.map { it.id } + vm.startDrag(before[0]) + vm.dragTo(2) + assertEquals(before[0], vm.state.value!!.active[2].id) + r.script.fail = OnlineResult.Failed("network", null) + vm.endDrag() + assertEquals(before, vm.state.value!!.active.map { it.id }) + assertEquals(DesignR.string.common_could_not_reach_server, vm.state.value!!.message!!.text) + r.script.fail = null + vm.move(before[0], 1) + assertEquals(before[1], vm.state.value!!.active[0].id) + } + + private fun editor(r: ScriptedSettingsRepositories, id: String?) = + CategoryEditorViewModel(SavedStateHandle(id?.let { mapOf(CategoryEditorViewModel.ARG_CATEGORY_ID to it) } ?: emptyMap()), r.categories, r.fakes.connectivity) + + @Test + fun categoryDeleteAffordanceFollowsDeletable() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + assertEquals(DeleteBlock.Default, editor(r, SettingsScenarios.groceries.id.value).state.value!!.deleteBlock) + assertEquals(DeleteBlock.InUseArchived, editor(r, SettingsScenarios.pant.id.value).state.value!!.deleteBlock) + val hund = editor(r, SettingsScenarios.hund.id.value) + assertNull(hund.state.value!!.deleteBlock) + r.script.fail = OnlineResult.Failed("category_in_use", null) + hund.askDelete() + assertTrue(hund.state.value!!.confirmDelete) + hund.delete() + assertEquals(DeleteBlock.InUse, hund.state.value!!.deleteBlock) + } + + @Test + fun categoryResetNameSavesNull() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded.copy(categories = SettingsScenarios.renamedCategories)) + val vm = editor(r, SettingsScenarios.groceries.id.value) + assertTrue(vm.state.value!!.canResetName) + vm.resetName() + vm.save() + assertEquals(EditorOutcome.Saved, vm.state.value!!.outcome) + assertNull(r.fakes.store.current.categories.first { it.id == SettingsScenarios.groceries.id }.customName) + } + + @Test + fun newCategoryNeedsAName() { + val vm = editor(ScriptedSettingsRepositories(SettingsScenarios.loaded), null) + vm.save() + assertFalse(vm.state.value!!.canSave) + assertEquals(se.hagstedt.kvitt.feature.settings.categories.CategoryNameError.Required, vm.state.value!!.nameError) + vm.onName("Hunden 2") + vm.save() + assertEquals(EditorOutcome.Saved, vm.state.value!!.outcome) + } + + @Test + fun archiveReturnsWithASnackbarForTheList() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + val vm = editor(r, SettingsScenarios.hund.id.value) + vm.toggleArchived("Hunden") + val outcome = vm.state.value!!.outcome as EditorOutcome.Archived + assertTrue(outcome.result.archived) + val list = CategoryListViewModel(r.categories, r.fakes.connectivity) + list.onArchiveResult(outcome.result) + assertEquals(R.string.settings_category_archived_snackbar, list.state.value!!.message!!.text) + } + + private fun recurring(r: ScriptedSettingsRepositories, id: String?) = RecurringEditorViewModel( + SavedStateHandle(id?.let { mapOf(RecurringEditorViewModel.ARG_TEMPLATE_ID to it) } ?: emptyMap()), + r.fakes.expenses, r.fakes.home, r.recurring, r.fakes.connectivity, + ) + + @Test + fun newTemplateDefaultsAndSave() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + val vm = recurring(r, null) + val s = vm.state.value!! + assertEquals(SettingsScenarios.huset.id, s.form.accountId) // AC-8.6 default account + assertEquals(SettingsScenarios.housing.id, s.form.categoryId) // last category of the account + assertEquals(EditorFocus.Amount, s.form.focus) + assertEquals("2026-10", s.startPeriod) // day 17 is today: this month (AC-9.8) + assertFalse(s.canSave) + "12400".forEach { vm.onKey(AmountKey.Digit(it - '0')) } + vm.onDescription("Hyra") + vm.onDay("25") + assertTrue(vm.state.value!!.canSave) + vm.save() + assertTrue(vm.state.value!!.done) + assertEquals(5, r.fakes.store.current.templates.size) + } + + @Test + fun accountChangeMovesTheDefaultCategoryUntilOneIsPicked() { + val vm = recurring(ScriptedSettingsRepositories(SettingsScenarios.loaded.copy(lastCategoryFor = mapOf(SettingsScenarios.huset.id to SettingsScenarios.housing.id, SettingsScenarios.vardag.id to SettingsScenarios.groceries.id))), null) + vm.onAccount(SettingsScenarios.vardag.id) + assertEquals(SettingsScenarios.groceries.id, vm.state.value!!.form.categoryId) + vm.onCategory(SettingsScenarios.entertainment.id) + vm.onAccount(SettingsScenarios.huset.id) + assertEquals(SettingsScenarios.entertainment.id, vm.state.value!!.form.categoryId) + } + + @Test + fun editTemplateLoadsSplitAndMarksServerErrors() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + val vm = recurring(r, SettingsScenarios.bredband.id.value) + val s = vm.state.value!! + assertEquals(SplitMode.RATIO, s.form.mode) + assertEquals(5_000, s.form.viewerBp) + assertFalse("unchanged", s.canSave) + vm.onViewerPercent(60) + assertEquals(6_000, vm.state.value!!.form.viewerBp) + r.script.fail = OnlineResult.Failed("validation_failed", "start_period") + vm.save() + assertEquals(StartError.Refused, vm.state.value!!.errors.start) + r.script.fail = OnlineResult.Failed("network", null) + vm.onViewerPercent(40) + vm.save() + assertTrue(vm.state.value!!.message!!.retry) + } + + @Test + fun editorCloseAsksBeforeDiscarding() { + val vm = recurring(ScriptedSettingsRepositories(SettingsScenarios.loaded), SettingsScenarios.hyra.id.value) + vm.close() + assertTrue("no changes: closes", vm.state.value!!.done) + val changed = recurring(ScriptedSettingsRepositories(SettingsScenarios.loaded), SettingsScenarios.hyra.id.value) + changed.onDescription("Hyra och el") + changed.close() + assertFalse(changed.state.value!!.done) + changed.discard() + assertTrue(changed.state.value!!.done) + } + + @Test + fun devicesLoadFailAndRevoke() { + val r = ScriptedSettingsRepositories(SettingsScenarios.loaded) + val vm = DevicesViewModel(r.devices, r.fakes.session, r.profile, r.fakes.connectivity) + val list = vm.state.value!!.list as DeviceList.Loaded + assertEquals(2, list.devices.size) // the revoked phone is not listed + val anna = list.devices.first { it.id == SettingsScenarios.annaPhone } + vm.askRevoke(anna) + vm.revoke() + assertEquals(1, (vm.state.value!!.list as DeviceList.Loaded).devices.size) + r.script.fail = OnlineResult.Failed("network", null) + vm.load() + assertEquals(DeviceList.Unavailable(unreachable = true), vm.state.value!!.list) + val offline = DevicesViewModel(ScriptedSettingsRepositories(SettingsScenarios.offline).let { it.devices }, r.fakes.session, r.profile, ScriptedSettingsRepositories(SettingsScenarios.offline).fakes.connectivity) + assertEquals(DeviceList.Unavailable(unreachable = false), offline.state.value!!.list) + } + + @Test + fun invitesCreateAndRefuse() { + val r = ScriptedSettingsRepositories(SettingsScenarios.partnerNotJoined) + val vm = InvitesViewModel(r.invites, r.fakes.session, r.profile, r.fakes.connectivity) + assertEquals(1, vm.state.value!!.pending!!.size) + vm.invitePartner() + assertEquals(SAMPLE_CODE, vm.state.value!!.created!!.code) + assertEquals(1, vm.state.value!!.pending!!.size) // the new partner invite replaces the pending one (AC-2.1) + r.script.fail = OnlineResult.Failed("household_full", null) + vm.invitePartner() + assertEquals(DesignR.string.error_household_full, vm.state.value!!.message!!.text) + assertNull(vm.state.value!!.created) + } +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt new file mode 100644 index 0000000..9037264 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/CategoriesScreenshotTest.kt @@ -0,0 +1,110 @@ +package se.hagstedt.kvitt.feature.settings.screenshot + +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories +import se.hagstedt.kvitt.fakes.settings.SettingsScenarios +import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorActions +import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorContent +import se.hagstedt.kvitt.feature.settings.categories.CategoryEditorViewModel +import se.hagstedt.kvitt.feature.settings.categories.CategoryListActions +import se.hagstedt.kvitt.feature.settings.categories.CategoryListContent +import se.hagstedt.kvitt.feature.settings.categories.CategoryListViewModel +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.OnlineResult + +/** S-15 Categories, list and editor (design-spec S-15 and PLAN-M7f §5). */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h914dp-xxhdpi") +class CategoriesScreenshotTest(private val state: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() = shots.run(state, compose) + + companion object { + private fun list(name: String, scenario: FakeScenario, scrollToEnd: Boolean = false, drive: (ScriptedSettingsRepositories, CategoryListViewModel) -> Unit = { _, _ -> }) = Shot("list_$name") { compose -> + val r = ScriptedSettingsRepositories(scenario) + val vm = CategoryListViewModel(r.categories, r.fakes.connectivity) + ScreenSnapshots.idle() + drive(r, vm) + val prepare: (androidx.compose.ui.test.junit4.ComposeContentTestRule) -> Unit = { c -> if (scrollToEnd) c.onNodeWithText("Pant").performScrollTo() } + ScreenSnapshots.snap(compose, "s15_categories_list_$name", prepare = prepare) { + val s by vm.state.collectAsStateWithLifecycle() + CategoryListContent(s, CategoryListActions()) + } + } + + private fun editor(name: String, scenario: FakeScenario, category: Category?, drive: (ScriptedSettingsRepositories, CategoryEditorViewModel) -> Unit = { _, _ -> }) = Shot("editor_$name") { compose -> + val r = ScriptedSettingsRepositories(scenario) + val handle = SavedStateHandle(category?.let { mapOf(CategoryEditorViewModel.ARG_CATEGORY_ID to it.id.value) } ?: emptyMap()) + val vm = CategoryEditorViewModel(handle, r.categories, r.fakes.connectivity) + ScreenSnapshots.idle() + drive(r, vm) + ScreenSnapshots.snap(compose, "s15_categories_editor_$name") { + val s by vm.state.collectAsStateWithLifecycle() + CategoryEditorContent(s, CategoryEditorActions()) + } + } + + private val renamed = SettingsScenarios.loaded.copy(categories = SettingsScenarios.renamedCategories) + + /** Pant unarchived and still used by an expense: delete blocked "in use, archive it instead". */ + private val pantActive = SettingsScenarios.loaded.copy(categories = SettingsScenarios.categories.map { if (it.id == SettingsScenarios.pant.id) it.copy(archived = false) else it }) + + + val shots = listOf( + list("loaded", SettingsScenarios.loaded), + list("archived_expanded", SettingsScenarios.loaded, scrollToEnd = true) { _, vm -> vm.toggleArchived() }, + list("reorder_in_progress", SettingsScenarios.loaded) { _, vm -> + vm.startDrag(SettingsScenarios.groceries.id) + vm.dragTo(2) + }, + list("error_network", SettingsScenarios.loaded) { r, vm -> + r.script.fail = OnlineResult.Failed("network", null) + vm.move(SettingsScenarios.groceries.id, 1) + }, + list("archived_snackbar", SettingsScenarios.loaded) { _, vm -> vm.onArchiveResult(se.hagstedt.kvitt.feature.settings.categories.ArchiveResult("Pant", archived = true)) }, + list("needs_connection", SettingsScenarios.offline), + list("long", SettingsScenarios.long), + editor("new", SettingsScenarios.loaded, null), + editor("default_renamed", renamed, SettingsScenarios.groceries), + editor("default", SettingsScenarios.loaded, SettingsScenarios.housing), + editor("custom_archived_in_use", SettingsScenarios.loaded, SettingsScenarios.pant), + editor("custom_in_use", pantActive, SettingsScenarios.pant), + editor("custom_deletable", SettingsScenarios.loaded, SettingsScenarios.hund), + editor("delete_confirm", SettingsScenarios.loaded, SettingsScenarios.hund) { _, vm -> vm.askDelete() }, + editor("delete_refused", SettingsScenarios.loaded, SettingsScenarios.hund) { r, vm -> + r.script.fail = OnlineResult.Failed("category_in_use", null) + vm.askDelete() + vm.delete() + }, + editor("name_required", SettingsScenarios.loaded, SettingsScenarios.hund) { _, vm -> vm.onName("") }, + editor("error_network", SettingsScenarios.loaded, SettingsScenarios.hund) { r, vm -> + r.script.fail = OnlineResult.Failed("network", null) + vm.onColor(se.hagstedt.kvitt.model.ColorToken.ORANGE) + ScreenSnapshots.idle() + vm.save() + }, + editor("needs_connection", SettingsScenarios.offline, SettingsScenarios.hund), + editor("long", SettingsScenarios.long, SettingsScenarios.hund), + ) + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun states(): List> = shots.parameters() + } +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt new file mode 100644 index 0000000..4cec936 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/DevicesInvitesAboutScreenshotTest.kt @@ -0,0 +1,123 @@ +package se.hagstedt.kvitt.feature.settings.screenshot + +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories +import se.hagstedt.kvitt.fakes.settings.SettingsScenarios +import se.hagstedt.kvitt.feature.settings.about.AboutActions +import se.hagstedt.kvitt.feature.settings.about.AboutContent +import se.hagstedt.kvitt.feature.settings.about.AboutViewModel +import se.hagstedt.kvitt.feature.settings.devices.DeviceList +import se.hagstedt.kvitt.feature.settings.devices.DevicesActions +import se.hagstedt.kvitt.feature.settings.devices.DevicesContent +import se.hagstedt.kvitt.feature.settings.devices.DevicesViewModel +import se.hagstedt.kvitt.feature.settings.invites.InvitesActions +import se.hagstedt.kvitt.feature.settings.invites.InvitesContent +import se.hagstedt.kvitt.feature.settings.invites.InvitesViewModel +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.UpdateState + +/** S-20 Devices, S-21 Invites and S-22 About (design-spec §4 and PLAN-M7f §5). */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h914dp-xxhdpi") +class DevicesInvitesAboutScreenshotTest(private val state: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() = shots.run(state, compose) + + companion object { + private fun devices(name: String, scenario: FakeScenario, before: (ScriptedSettingsRepositories) -> Unit = {}, drive: (ScriptedSettingsRepositories, DevicesViewModel) -> Unit = { _, _ -> }) = Shot("s20_devices_$name") { compose -> + val r = ScriptedSettingsRepositories(scenario) + before(r) + val vm = DevicesViewModel(r.devices, r.fakes.session, r.profile, r.fakes.connectivity) + ScreenSnapshots.idle() + drive(r, vm) + ScreenSnapshots.snap(compose, "s20_devices_$name") { + val s by vm.state.collectAsStateWithLifecycle() + DevicesContent(s, DevicesActions()) + } + } + + private fun annaPhone(vm: DevicesViewModel) = (vm.state.value?.list as DeviceList.Loaded).devices.first { it.id == SettingsScenarios.annaPhone } + + private fun invites(name: String, scenario: FakeScenario, drive: (ScriptedSettingsRepositories, InvitesViewModel) -> Unit = { _, _ -> }) = Shot("s21_invites_$name") { compose -> + val r = ScriptedSettingsRepositories(scenario) + val vm = InvitesViewModel(r.invites, r.fakes.session, r.profile, r.fakes.connectivity) + ScreenSnapshots.idle() + drive(r, vm) + ScreenSnapshots.snap(compose, "s21_invites_$name") { + val s by vm.state.collectAsStateWithLifecycle() + InvitesContent(s, InvitesActions()) + } + } + + private fun about(name: String, scenario: FakeScenario, drive: (ScriptedSettingsRepositories, AboutViewModel) -> Unit = { _, _ -> }) = Shot("s22_about_$name") { compose -> + val r = ScriptedSettingsRepositories(scenario) + val vm = AboutViewModel(r.fakes.about, r.update, r.fakes.connectivity) + ScreenSnapshots.idle() + drive(r, vm) + ScreenSnapshots.snap(compose, "s22_about_$name") { + val s by vm.state.collectAsStateWithLifecycle() + AboutContent(s, AboutActions(), updateCheckAvailable = true) + } + } + + val shots = listOf( + devices("admin", SettingsScenarios.loaded), + devices("member", SettingsScenarios.member), + devices("revoke_confirm", SettingsScenarios.loaded) { _, vm -> vm.askRevoke(annaPhone(vm)) }, + devices("error_cannot_revoke_current", SettingsScenarios.loaded) { r, vm -> + vm.askRevoke(annaPhone(vm)) + r.script.fail = OnlineResult.Failed("cannot_revoke_current_device", null) + vm.revoke() + }, + devices("loading", SettingsScenarios.loaded, before = { it.script.hang = true }), + devices("unreachable", SettingsScenarios.loaded, before = { it.script.fail = OnlineResult.Failed("network", null) }), + devices("needs_connection", SettingsScenarios.offline), + devices("long", SettingsScenarios.long), + invites("before_partner", SettingsScenarios.partnerNotJoined), + invites("after_partner", SettingsScenarios.loaded), + invites("created_device", SettingsScenarios.loaded) { _, vm -> vm.inviteDevice(SettingsScenarios.jacob.id) }, + invites("created_partner", SettingsScenarios.partnerNotJoined) { _, vm -> vm.invitePartner() }, + invites("cancel_confirm", SettingsScenarios.loaded) { _, vm -> vm.askCancel(vm.state.value?.pending.orEmpty().first()) }, + invites("error_household_full", SettingsScenarios.partnerNotJoined) { r, vm -> + r.script.fail = OnlineResult.Failed("household_full", null) + vm.invitePartner() + }, + invites("error_network", SettingsScenarios.loaded) { r, vm -> + r.script.fail = OnlineResult.Failed("network", null) + vm.inviteDevice(SettingsScenarios.anna.id) + }, + invites("empty", SettingsScenarios.empty), + invites("needs_connection", SettingsScenarios.offline), + about("online", SettingsScenarios.loaded), + about("offline", SettingsScenarios.offline), + about("check_up_to_date", SettingsScenarios.loaded) { _, vm -> vm.check() }, + about("check_update_found", SettingsScenarios.loaded) { r, vm -> + r.afterCheck = UpdateState.Available(SettingsScenarios.update) + vm.check() + }, + about("checking", SettingsScenarios.loaded) { r, vm -> + r.script.hang = true + vm.check() + }, + about("downloading", SettingsScenarios.updateDownloading), + about("update_failed", SettingsScenarios.loaded.copy(update = UpdateState.Failed(SettingsScenarios.update))), + ) + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun states(): List> = shots.parameters() + } +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt new file mode 100644 index 0000000..cdcdfb3 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/HubScreenshotTest.kt @@ -0,0 +1,55 @@ +package se.hagstedt.kvitt.feature.settings.screenshot + +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories +import se.hagstedt.kvitt.fakes.settings.SettingsScenarios +import se.hagstedt.kvitt.feature.settings.hub.SettingsHubActions +import se.hagstedt.kvitt.feature.settings.hub.SettingsHubContent +import se.hagstedt.kvitt.feature.settings.hub.SettingsHubViewModel + +/** S-13 Settings hub: every state × sv/en × light/dark (design-spec S-13). */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h914dp-xxhdpi") +class HubScreenshotTest(private val state: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() = shots.run(state, compose) + + companion object { + fun viewModel(r: ScriptedSettingsRepositories) = + SettingsHubViewModel(r.fakes.session, r.profile, r.categories, r.recurring, r.fakes.sync, r.fakes.about, r.update, r.fakes.connectivity) + + private fun shot(name: String, scenario: FakeScenario) = Shot(name) { compose -> + val vm = viewModel(ScriptedSettingsRepositories(scenario)) + ScreenSnapshots.snap(compose, "s13_hub_$name") { + val s by vm.state.collectAsStateWithLifecycle() + SettingsHubContent(s, SettingsHubActions()) + } + } + + val shots = listOf( + shot("admin", SettingsScenarios.loaded), + shot("member", SettingsScenarios.member), + shot("partner_not_joined", SettingsScenarios.partnerNotJoined), + shot("offline", SettingsScenarios.offline), + shot("update_available", SettingsScenarios.updateAvailable), + shot("long", SettingsScenarios.long), + ) + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun states(): List> = shots.parameters() + } +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt new file mode 100644 index 0000000..794eecb --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ProfileScreenshotTest.kt @@ -0,0 +1,70 @@ +package se.hagstedt.kvitt.feature.settings.screenshot + +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories +import se.hagstedt.kvitt.fakes.settings.SettingsScenarios +import se.hagstedt.kvitt.feature.settings.profile.ProfileActions +import se.hagstedt.kvitt.feature.settings.profile.ProfileContent +import se.hagstedt.kvitt.feature.settings.profile.ProfileViewModel +import se.hagstedt.kvitt.model.OnlineResult + +/** S-14 Profile (design-spec S-14 and PLAN-M7f §5). */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h914dp-xxhdpi") +class ProfileScreenshotTest(private val state: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() = shots.run(state, compose) + + companion object { + private val jpeg = ByteArray(16) + + private fun shot(name: String, scenario: FakeScenario, picture: Boolean = false, drive: (ScriptedSettingsRepositories, ProfileViewModel) -> Unit = { _, _ -> }) = Shot(name) { compose -> + val r = ScriptedSettingsRepositories(scenario) + val vm = ProfileViewModel(r.profile, r.fakes.session, r.fakes.connectivity) + ScreenSnapshots.idle() + drive(r, vm) + ScreenSnapshots.snap(compose, "s14_profile_$name", picture) { + val s by vm.state.collectAsStateWithLifecycle() + ProfileContent(s, ProfileActions()) + } + } + + private fun failing(code: String): (ScriptedSettingsRepositories, ProfileViewModel) -> Unit = { r, vm -> + r.script.fail = OnlineResult.Failed(code, null) + vm.onPicture(jpeg, "image/jpeg") + } + + val shots = listOf( + shot("with_picture", SettingsScenarios.withPicture, picture = true), + shot("initials", SettingsScenarios.loaded), + shot("uploading", SettingsScenarios.loaded) { r, vm -> + r.script.hang = true + vm.onPicture(jpeg, "image/jpeg") + }, + shot("name_edited", SettingsScenarios.loaded) { _, vm -> vm.onName("Jacob H") }, + shot("name_required", SettingsScenarios.loaded) { _, vm -> vm.onName("") }, + shot("error_image_invalid", SettingsScenarios.loaded, drive = failing("image_invalid")), + shot("error_payload_too_large", SettingsScenarios.loaded, drive = failing("payload_too_large")), + shot("error_network", SettingsScenarios.loaded, drive = failing("network")), + shot("needs_connection", SettingsScenarios.offline), + shot("long", SettingsScenarios.long), + ) + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun states(): List> = shots.parameters() + } +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt new file mode 100644 index 0000000..704aaaa --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt @@ -0,0 +1,144 @@ +package se.hagstedt.kvitt.feature.settings.screenshot + +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import se.hagstedt.kvitt.design.format.AmountKey +import se.hagstedt.kvitt.fakes.FakeScenario +import se.hagstedt.kvitt.fakes.settings.ScriptedSettingsRepositories +import se.hagstedt.kvitt.fakes.settings.SettingsScenarios +import se.hagstedt.kvitt.feature.settings.recurring.EditorFocus +import se.hagstedt.kvitt.feature.settings.recurring.MonthField +import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorActions +import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorContent +import se.hagstedt.kvitt.feature.settings.recurring.RecurringEditorViewModel +import se.hagstedt.kvitt.feature.settings.recurring.RecurringListActions +import se.hagstedt.kvitt.feature.settings.recurring.RecurringListContent +import se.hagstedt.kvitt.feature.settings.recurring.RecurringListViewModel +import se.hagstedt.kvitt.model.OnlineResult +import se.hagstedt.kvitt.model.RecurringTemplate + +/** S-19 Recurring expenses, list and editor (design-spec S-19 and PLAN-M7f §5). */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h914dp-xxhdpi") +class RecurringScreenshotTest(private val state: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun screen() = shots.run(state, compose) + + companion object { + private fun list(name: String, scenario: FakeScenario) = Shot("list_$name") { compose -> + val r = ScriptedSettingsRepositories(scenario) + val f = r.fakes + val vm = RecurringListViewModel(r.recurring, f.accounts, r.categories, f.session, r.profile, f.connectivity) + ScreenSnapshots.snap(compose, "s19_recurring_list_$name") { + val s by vm.state.collectAsStateWithLifecycle() + RecurringListContent(s, RecurringListActions()) + } + } + + private fun editor( + name: String, + scenario: FakeScenario, + template: RecurringTemplate?, + scrollTo: String? = null, + drive: (ScriptedSettingsRepositories, RecurringEditorViewModel) -> Unit = { _, _ -> }, + ) = Shot("editor_$name") { compose -> + val r = ScriptedSettingsRepositories(scenario) + val f = r.fakes + val handle = SavedStateHandle(template?.let { mapOf(RecurringEditorViewModel.ARG_TEMPLATE_ID to it.id.value) } ?: emptyMap()) + val vm = RecurringEditorViewModel(handle, f.expenses, f.home, r.recurring, f.connectivity) + ScreenSnapshots.idle() + drive(r, vm) + ScreenSnapshots.idle() + ScreenSnapshots.snap(compose, "s19_recurring_editor_$name", prepare = { c -> if (scrollTo != null) c.onNodeWithText(scrollTo, substring = true).performScrollTo() }) { + val s by vm.state.collectAsStateWithLifecycle() + RecurringEditorContent(s, RecurringEditorActions()) + } + } + + private fun RecurringEditorViewModel.type(text: String) = text.forEach { c -> + onKey(if (c == ',') AmountKey.Decimal else AmountKey.Digit(c - '0')) + } + + /** "Hyra 12 400 kr" typed into a new template. */ + private val newHyra: (ScriptedSettingsRepositories, RecurringEditorViewModel) -> Unit = { _, vm -> + vm.type("12400") + vm.onDescription("Hyra") + vm.focus(EditorFocus.None) + } + + val shots = listOf( + list("loaded", SettingsScenarios.loaded), + list("empty", SettingsScenarios.empty), + list("needs_connection", SettingsScenarios.offline), + list("long", SettingsScenarios.long), + editor("new", SettingsScenarios.loaded, null), + editor("new_typed", SettingsScenarios.loaded, null) { _, vm -> vm.type("12400") }, + editor("edit_default", SettingsScenarios.loaded, SettingsScenarios.hyra), + editor("edit_default_split_open", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm -> vm.toggleSplit() }, + editor("edit_ratio", SettingsScenarios.loaded, SettingsScenarios.bredband) { _, vm -> vm.toggleSplit() }, + editor("edit_exact", SettingsScenarios.loaded, SettingsScenarios.netflix) { _, vm -> + vm.toggleSplit() + vm.focus(EditorFocus.None) + }, + editor("edit_exact_over", SettingsScenarios.loaded, SettingsScenarios.netflix) { _, vm -> + vm.toggleSplit() + vm.focus(EditorFocus.Exact) + repeat(4) { vm.onKey(AmountKey.Delete) } + vm.type("200") + }, + editor("edit_full", SettingsScenarios.loaded, SettingsScenarios.spotify) { _, vm -> vm.toggleSplit() }, + editor("edit_paused_resumed", SettingsScenarios.loaded, SettingsScenarios.spotify, scrollTo = "06:00") { _, vm -> vm.onPaused(false) }, + editor("backfill", SettingsScenarios.loaded, null, scrollTo = "06:00") { r, vm -> + newHyra(r, vm) + vm.onMonth(MonthField.First, "2025-11") + }, + editor("end_period", SettingsScenarios.loaded, SettingsScenarios.netflix, scrollTo = "06:00"), + editor("end_before_start", SettingsScenarios.loaded, SettingsScenarios.hyra, scrollTo = "06:00") { _, vm -> vm.onMonth(MonthField.Last, "2026-05") }, + editor("day_clamp", SettingsScenarios.loaded, SettingsScenarios.hyra, scrollTo = "06:00") { _, vm -> vm.onDay("31") }, + editor("month_picker", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm -> vm.openMonth(MonthField.First) }, + editor("error_validation", SettingsScenarios.loaded, null, scrollTo = "06:00") { r, vm -> + newHyra(r, vm) + r.script.fail = OnlineResult.Failed("validation_failed", "start_period") + ScreenSnapshots.idle() + vm.save() + }, + editor("error_network", SettingsScenarios.loaded, SettingsScenarios.hyra) { r, vm -> + r.script.fail = OnlineResult.Failed("network", null) + vm.onPaused(true) + ScreenSnapshots.idle() + vm.save() + }, + editor("required", SettingsScenarios.loaded, null) { _, vm -> + vm.focus(EditorFocus.None) + ScreenSnapshots.idle() + vm.save() + }, + editor("delete_confirm", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm -> vm.askDelete() }, + editor("discard", SettingsScenarios.loaded, SettingsScenarios.hyra) { _, vm -> + vm.onDescription("Hyra och el") + ScreenSnapshots.idle() + vm.close() + }, + editor("needs_connection", SettingsScenarios.offline, SettingsScenarios.hyra), + editor("long", SettingsScenarios.long, SettingsScenarios.long.templates.first()), + ) + + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun states(): List> = shots.parameters() + } +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ScreenSnapshots.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ScreenSnapshots.kt new file mode 100644 index 0000000..c6bce24 --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/ScreenSnapshots.kt @@ -0,0 +1,115 @@ +package se.hagstedt.kvitt.feature.settings.screenshot + +import android.content.res.Configuration +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import android.os.Looper +import android.os.LocaleList +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.unit.Density +import com.github.takahirom.roborazzi.ExperimentalRoborazziApi +import com.github.takahirom.roborazzi.captureScreenRoboImage +import kotlinx.datetime.TimeZone +import org.robolectric.Shadows.shadowOf +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AvatarPictures +import se.hagstedt.kvitt.design.component.KvittClock +import se.hagstedt.kvitt.design.component.LocalAvatarPictures +import se.hagstedt.kvitt.design.component.LocalKvittClock +import java.util.Locale +import kotlin.time.Instant + +/** + * Whole-screen goldens of `:feature:settings` (M7f), following `:core:design`'s pattern: one capture per language and + * theme, `src/test/screenshots/__.png`. The screen (Robolectric `w411dp-h914dp`, the Galaxy + * S26) is captured with its dialogs. Content is set once; language and theme switch through a configuration context. + * The clock is 17 October 2026 14:02 Stockholm, as in the fakes' scenarios. + */ +object ScreenSnapshots { + val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm")) + + private data class Variant(val language: String, val dark: Boolean) { + val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + } + + /** Runs pending main-thread work (ViewModel coroutines) before a capture. */ + fun idle() = shadowOf(Looper.getMainLooper()).idle() + + @OptIn(ExperimentalRoborazziApi::class) + fun snap(compose: ComposeContentTestRule, name: String, picture: Boolean = false, prepare: (ComposeContentTestRule) -> Unit = {}, content: @Composable () -> Unit) { + val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it) } } + var current by mutableStateOf(variants.first()) + compose.setContent { + val variant = current + val base = LocalContext.current + val baseConfiguration = LocalConfiguration.current + val context = remember(variant) { + val config = Configuration(baseConfiguration).apply { + setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB"))) + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO + } + base.createConfigurationContext(config) + } + val pictures = remember { if (picture) AvatarPictures { user -> if (user.pictureVersion != null) portrait else null } else AvatarPictures { null } } + CompositionLocalProvider( + LocalContext provides context, + LocalConfiguration provides context.resources.configuration, + LocalResources provides context.resources, + LocalDensity provides Density(context), + LocalKvittClock provides clock, + LocalAvatarPictures provides pictures, + // Coil loads synchronously in inspection mode, so a picture is drawn before the capture. + LocalInspectionMode provides picture, + ) { + KvittTheme(darkTheme = variant.dark) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { content() } + } + } + } + idle() + compose.waitForIdle() + prepare(compose) + variants.forEach { variant -> + current = variant + idle() + compose.waitForIdle() + captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") + } + } + + /** A stand-in profile picture: a warm gradient with a lighter disc (no photo of a person is committed). */ + private val portrait: Bitmap by lazy { + val size = PICTURE_SIZE + val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(bitmap) + val paint = Paint(Paint.ANTI_ALIAS_FLAG) + paint.shader = LinearGradient(0f, 0f, size.toFloat(), size.toFloat(), Color.rgb(0xC9, 0x8B, 0x6B), Color.rgb(0x6E, 0x3B, 0x2A), Shader.TileMode.CLAMP) + canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), paint) + paint.shader = null + paint.color = Color.rgb(0xF2, 0xD9, 0xC4) + canvas.drawCircle(size / 2f, size * 0.4f, size * 0.2f, paint) + canvas.drawCircle(size / 2f, size * 1.05f, size * 0.38f, paint) + bitmap + } + + private const val PICTURE_SIZE = 256 +} diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt new file mode 100644 index 0000000..e12a8bf --- /dev/null +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/Shots.kt @@ -0,0 +1,11 @@ +package se.hagstedt.kvitt.feature.settings.screenshot + +import androidx.compose.ui.test.junit4.ComposeContentTestRule + +/** One screenshot state: [run] builds the ViewModel from the fakes, drives it and calls [ScreenSnapshots.snap]. */ +class Shot(val name: String, val run: (ComposeContentTestRule) -> Unit) + +/** The parameter list of a screen's test: one entry per state. */ +fun List.parameters(): List> = map { arrayOf(it.name) } + +fun List.run(name: String, compose: ComposeContentTestRule) = first { it.name == name }.run(compose)