diff --git a/android/core/design/build.gradle.kts b/android/core/design/build.gradle.kts index bfd942b..73fcbaf 100644 --- a/android/core/design/build.gradle.kts +++ b/android/core/design/build.gradle.kts @@ -1,9 +1,24 @@ -// :core:design — theme, tokens, shared components and formatters. M2b ships only the placeholder theme; M6 owns it. +// :core:design — theme, tokens, shared components, charts, formatters and shared strings (SPEC §6.2, docs/ux/design-spec.md +// §2 and §3). Owned by M6; feature modules (M7x) consume it and may not edit it. plugins { id("kvitt.android.library") id("kvitt.android.compose") + alias(libs.plugins.roborazzi) +} + +roborazzi { + outputDir.set(file("src/test/screenshots")) } dependencies { api(project(":core:model")) + // Avatars: pictures are loaded by Coil; :app supplies the request model through LocalAvatarPictures (design-spec §3.3). + implementation(libs.coil.compose) + + debugImplementation(libs.androidx.compose.ui.test.manifest) + 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/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt new file mode 100644 index 0000000..c340b8a --- /dev/null +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt @@ -0,0 +1,749 @@ +package se.hagstedt.kvitt.design.catalogue + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.chart.AnalyticsFilterBar +import se.hagstedt.kvitt.design.chart.BreakdownList +import se.hagstedt.kvitt.design.chart.BreakdownRow +import se.hagstedt.kvitt.design.chart.ChartCard +import se.hagstedt.kvitt.design.chart.ChartGroup +import se.hagstedt.kvitt.design.chart.ChartLegend +import se.hagstedt.kvitt.design.chart.FilterToggle +import se.hagstedt.kvitt.design.chart.StackedBarChart +import se.hagstedt.kvitt.design.chart.TotalCard +import se.hagstedt.kvitt.design.component.AccountBalanceHeader +import se.hagstedt.kvitt.design.component.AccountCard +import se.hagstedt.kvitt.design.component.AccountChip +import se.hagstedt.kvitt.design.component.AccountChoice +import se.hagstedt.kvitt.design.component.AccountHeaderWords +import se.hagstedt.kvitt.design.component.AccountPicker +import se.hagstedt.kvitt.design.component.AccountTile +import se.hagstedt.kvitt.design.component.AddExpenseFab +import se.hagstedt.kvitt.design.component.AllocationRow +import se.hagstedt.kvitt.design.component.AllocationWords +import se.hagstedt.kvitt.design.component.AmountDisplay +import se.hagstedt.kvitt.design.component.AmountKeypad +import se.hagstedt.kvitt.design.component.AmountStyle +import se.hagstedt.kvitt.design.component.AmountTone +import se.hagstedt.kvitt.design.component.AvatarPlaceholder +import se.hagstedt.kvitt.design.component.AvatarShape +import se.hagstedt.kvitt.design.component.AvatarState +import se.hagstedt.kvitt.design.component.BadgeKind +import se.hagstedt.kvitt.design.component.BalanceHero +import se.hagstedt.kvitt.design.component.BalanceScope +import se.hagstedt.kvitt.design.component.BalanceWording +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.ButtonSize +import se.hagstedt.kvitt.design.component.CategoryGrid +import se.hagstedt.kvitt.design.component.CategoryIconPicker +import se.hagstedt.kvitt.design.component.CategoryQuickRow +import se.hagstedt.kvitt.design.component.CategoryTile +import se.hagstedt.kvitt.design.component.CollapsibleRow +import se.hagstedt.kvitt.design.component.ColorSwatchPicker +import se.hagstedt.kvitt.design.component.CompactEditorSummary +import se.hagstedt.kvitt.design.component.ConfirmDialogContent +import se.hagstedt.kvitt.design.component.DateChip +import se.hagstedt.kvitt.design.component.DescriptionField +import se.hagstedt.kvitt.design.component.DirectionCard +import se.hagstedt.kvitt.design.component.DirectionWords +import se.hagstedt.kvitt.design.component.EditorChipRow +import se.hagstedt.kvitt.design.component.EffectLine +import se.hagstedt.kvitt.design.component.EmptyState +import se.hagstedt.kvitt.design.component.ExactSplitEditor +import se.hagstedt.kvitt.design.component.FactRow +import se.hagstedt.kvitt.design.component.FullSplitPicker +import se.hagstedt.kvitt.design.component.GroupFill +import se.hagstedt.kvitt.design.component.HeroState +import se.hagstedt.kvitt.design.component.HeroWords +import se.hagstedt.kvitt.design.component.InviteCodeDisplay +import se.hagstedt.kvitt.design.component.InviteCodeField +import se.hagstedt.kvitt.design.component.KvittBadge +import se.hagstedt.kvitt.design.component.KvittButton +import se.hagstedt.kvitt.design.component.KvittDestination +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.KvittMenuContent +import se.hagstedt.kvitt.design.component.KvittMenuItem +import se.hagstedt.kvitt.design.component.KvittNavigationBar +import se.hagstedt.kvitt.design.component.KvittSnackbar +import se.hagstedt.kvitt.design.component.KvittTopAppBar +import se.hagstedt.kvitt.design.component.LedgerMonthGroup +import se.hagstedt.kvitt.design.component.MoneyText +import se.hagstedt.kvitt.design.component.NeedsConnectionBanner +import se.hagstedt.kvitt.design.component.NeedsConnectionHint +import se.hagstedt.kvitt.design.component.NotesField +import se.hagstedt.kvitt.design.component.PayerChip +import se.hagstedt.kvitt.design.component.PersonAvatar +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.RemainingIndicator +import se.hagstedt.kvitt.design.component.SectionHeader +import se.hagstedt.kvitt.design.component.SegmentedGroup +import se.hagstedt.kvitt.design.component.SettingsRow +import se.hagstedt.kvitt.design.component.Skeleton +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.SplitSlider +import se.hagstedt.kvitt.design.component.SyncStatusPill +import se.hagstedt.kvitt.design.component.TileStyle +import se.hagstedt.kvitt.design.component.Timeline +import se.hagstedt.kvitt.design.component.TopBarNavigation +import se.hagstedt.kvitt.design.component.UpdateBanner +import se.hagstedt.kvitt.design.component.categoryName +import se.hagstedt.kvitt.design.component.defaultModeLabel +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.money +import se.hagstedt.kvitt.design.component.quickRowCategories +import se.hagstedt.kvitt.design.component.ratioSpoken +import se.hagstedt.kvitt.design.format.AmountInput +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.format.KvittLanguage +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.ExpenseEffect +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.UpdateState +import kotlinx.datetime.LocalDate + +// The preview catalogue: one sheet per component of docs/ux/design-spec.md §3, with its states. The screenshot tests +// render exactly these sheets, so a preview and its golden never drift apart. + +/** A feature word for the catalogue: the Swedish or English text of its `strings.csv` row (feature modules own it). */ +@Composable +fun w(sv: String, en: String): String = if (kvittLanguage() == KvittLanguage.SWEDISH) sv else en + +@Composable +private fun Label(text: String) { + Text(text, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) +} + +@Composable +fun MoneyTextSheet() { + MoneyText(325_000, AmountStyle.Hero, AmountTone.Owing) + MoneyText(123_456_789, AmountStyle.Hero, AmountTone.Owed) + MoneyText(118_450, AmountStyle.Large, AmountTone.Owed) + MoneyText(124_760, AmountStyle.Medium, AmountTone.Neutral) + MoneyText(18_990, AmountStyle.Small, AmountTone.Muted) + MoneyText(0, AmountStyle.Small, AmountTone.Settled) +} + +@Composable +fun WordsSheet() { + EffectLine(ExpenseEffect.Lent(20_600)) + EffectLine(ExpenseEffect.Borrowed(62_380)) + EffectLine(ExpenseEffect.NotInvolved) + EffectLine(ExpenseEffect.PaidForYourself) + BalanceWording(118_450, Samples.anna, BalanceScope.Account) + BalanceWording(-325_000, Samples.anna, BalanceScope.Global) + BalanceWording(0, Samples.anna, BalanceScope.Global) + BalanceWording(0, Samples.anna, BalanceScope.Account) +} + +@Composable +fun AvatarSheet() { + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + listOf(KvittDimens.avatarSmall, KvittDimens.avatarChip, KvittDimens.avatarRow, KvittDimens.avatarSplit, KvittDimens.avatarDirection, KvittDimens.avatarSeesaw) + .forEach { PersonAvatar(Samples.jacob, it) } + } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + PersonAvatar(Samples.anna, KvittDimens.avatarSettings, shape = AvatarShape.Large) + PersonAvatar(Samples.annaLong, KvittDimens.avatarIntro, shape = AvatarShape.Large) + PersonAvatar(Samples.jacob, KvittDimens.avatarIntro, shape = AvatarShape.Large, state = AvatarState.Uploading) + AvatarPlaceholder(KvittDimens.avatarSeesaw) + } +} + +@Composable +fun CategoryTileSheet() { + Label(w("Fyllda, 40 dp", "Solid, 40 dp")) + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { Samples.defaults.take(7).forEach { CategoryTile(it, KvittDimens.tileLedger) } } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { Samples.defaults.drop(7).forEach { CategoryTile(it, KvittDimens.tileLedger) } } + Label(w("Behållare, 52 dp", "Container, 52 dp")) + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { Samples.defaults.take(6).forEach { CategoryTile(it, KvittDimens.tileEditor, style = TileStyle.Container) } } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { Samples.defaults.drop(6).take(6).forEach { CategoryTile(it, KvittDimens.tileEditor, style = TileStyle.Container) } } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + listOf(KvittDimens.tileBreakdown, KvittDimens.tileSmall, KvittDimens.tileHeader, KvittDimens.tileHero, KvittDimens.tilePreview).forEach { CategoryTile(Samples.groceries, it) } + CategoryTile(Samples.pant, KvittDimens.tileLedger) + Text(categoryName(Samples.pant), style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +fun AccountTileSheet() { + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + listOf("Vardag", "Huset", "Japan 2027", "Gotland 2026", "Ölandsresan", "Barnen", "Ekonomi").forEachIndexed { i, name -> AccountTile(name, i) } + } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + listOf(KvittDimens.tileBreakdown, KvittDimens.tileSmall, KvittDimens.tileLedger, KvittDimens.accountTile).forEach { AccountTile("Vardag", 0, size = it) } + AccountTile("Gotland 2026", 3, muted = true) + } +} + +@Composable +private fun expensesLine(count: Int): String { + val month = w("oktober", "October") + return if (count == 0) w("Inga utgifter i $month", "No expenses in $month") + else w("$count ${if (count == 1) "utgift" else "utgifter"} i $month", "$count ${if (count == 1) "expense" else "expenses"} in $month") +} + +@Composable +fun AccountCardSheet() { + listOf(Samples.vardagSummary, Samples.husetSummary, Samples.gotlandSummary, Samples.longSummary).forEachIndexed { i, summary -> + AccountCard(summary, Samples.anna, colorIndex = if (summary == Samples.longSummary) 4 else i, expensesLine = expensesLine(summary.expensesThisMonth), onClick = {}) + } +} + +@Composable +fun IconPickerSheet() { + CategoryIconPicker("shopping_cart", ColorToken.GREEN, {}) +} + +@Composable +fun ColorSwatchSheet() { + ColorSwatchPicker(ColorToken.SLATE, {}) +} + +@Composable +fun LedgerSheet() { + LedgerMonthGroup(Samples.october.period, Samples.october.householdSpendOre, Samples.october.rows, w("Betalning", "Payment"), {}, {}) + LedgerMonthGroup(Samples.september2025.period, Samples.september2025.householdSpendOre, Samples.september2025.rows + Samples.settlementToAnna, w("Betalning", "Payment"), {}, {}) +} + +@Composable +fun heroWords(): HeroWords = HeroWords( + settleUp = w("Gör upp", "Settle up"), + loadingTitle = w("Hämtar era utgifter…", "Getting your expenses…"), + loadingBody = w("Första synken hämtar allt som finns på servern. Det tar bara en stund.", "The first sync fetches everything on the server. It only takes a moment."), + waitingTitle = w("Väntar på din partner", "Waiting for your partner"), + waitingBody = w("Kvitt behöver er båda innan ni kan börja. Bjud in din partner med en länk.", "Kvitt needs both of you before you can start. Invite your partner with a link."), + waitingPartnerLabel = w("Partner", "Partner"), + waitingSeatDescription = w("Partner saknas", "Partner has not joined"), +) + +@Composable +fun BalanceHeroSheet(state: HeroState) { + BalanceHero(state, Samples.jacob, heroWords(), {}, waitingAction = if (state == HeroState.Waiting) { + { KvittButton(w("Bjud in partner", "Invite partner"), {}, size = ButtonSize.Medium, icon = KvittIcon.PersonAdd) } + } else { + null + }) +} + +@Composable +fun AccountHeaderSheet() { + val words = AccountHeaderWords( + defaultSplit = w("50 / 50 · Jacob och Anna", "50 / 50 · Jacob and Anna"), + defaultSplitSpoken = w("Standarddelning: Jacob 50 procent, Anna 50 procent", "Default split: Jacob 50 percent, Anna 50 percent"), + settleAccount = w("Gör upp det här kontot", "Settle this account"), + unarchive = w("Återställ från arkiv", "Restore from archive"), + ) + AccountBalanceHeader(Samples.vardagDetail, 0, 50f, words, {}, {}) + AccountBalanceHeader(Samples.gotlandDetail, 3, 50f, words, {}, {}) + AccountBalanceHeader(Samples.husetDetail.copy(account = Samples.huset.copy(archived = true)), 1, 60f, words.copy(defaultSplit = w("60 / 40 · Jacob och Anna", "60 / 40 · Jacob and Anna")), {}, {}) +} + +@Composable +fun SyncPillSheet() { + SyncStatusPill(Samples.syncedSummary, online = true, onClick = {}) + SyncStatusPill(Samples.pendingSummary, online = true, onClick = {}) + SyncStatusPill(Samples.runningSummary, online = true, onClick = {}) + SyncStatusPill(Samples.pendingSummary, online = false, onClick = {}) + SyncStatusPill(Samples.rejectedSummary, online = true, onClick = {}) + SyncStatusPill(Samples.clockSummary, online = true, onClick = {}) + SyncStatusPill(Samples.failedSummary, online = true, onClick = {}, failed = true) +} + +@Composable +fun BannerSheet() { + Banner(BannerKind.Neutral, KvittIcon.Delete, w("Anna tog bort den här utgiften · 17 okt. 09:12", "Anna deleted this expense · 17 Oct 09:12"), body = w("Den räknas inte längre i saldon eller statistik.", "It no longer counts in balances or statistics."), iconTint = MaterialTheme.colorScheme.error) + Banner(BannerKind.Error, KvittIcon.Schedule, w("Telefonens klocka går fel. Rätta den och synka igen.", "Your phone's clock is wrong. Fix it and sync again."), body = w("Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess.", "The clock is more than 10 minutes ahead of the server. Your changes stay on the phone until then.")) { content -> + KvittButton(w("Datum och tid", "Date and time"), {}, kind = ButtonKind.Text, icon = KvittIcon.OpenInNew, contentColor = content) + } + NeedsConnectionBanner(w("Konton sparas direkt på servern. Anslut telefonen till internet för att skapa kontot.", "Accounts are saved straight on the server. Connect the phone to the internet to create the account.")) + KvittButton(w("Nytt konto", "New account"), {}, kind = ButtonKind.Text, icon = KvittIcon.Add, enabled = false) + NeedsConnectionHint() +} + +@Composable +fun UpdateBannerSheet() { + UpdateBanner(UpdateState.Available(Samples.update), online = true, {}, {}, {}) + UpdateBanner(UpdateState.Available(Samples.update), online = false, {}, {}, {}) + UpdateBanner(UpdateState.Downloading(Samples.update, 0.64f), online = true, {}, {}, {}) + UpdateBanner(UpdateState.Failed(Samples.update), online = true, {}, {}, {}) +} + +@Composable +fun AmountDisplaySheet() { + val language = kvittLanguage() + AmountDisplay(AmountInput("412").display(language), focused = true, error = null, onClick = {}) + AmountDisplay(AmountInput("1060.5").display(language), focused = false, error = null, onClick = {}) + AmountDisplay("", focused = false, error = w("Skriv ett belopp", "Enter an amount"), onClick = {}) +} + +@Composable +fun KeypadSheet() { + AmountKeypad({}) + AmountKeypad({}, compact = true) +} + +@Composable +fun TextFieldSheet() { + DescriptionField("Systembolaget", {}, error = null, onDone = {}) + DescriptionField("", {}, error = w("Skriv en beskrivning", "Enter a description"), onDone = {}) + NotesField("", {}, expanded = false, onExpand = {}) + NotesField("Storhandling inför middagen med Lindqvists", {}, expanded = true, onExpand = {}) +} + +@Composable +fun QuickRowSheet() { + val row = quickRowCategories(Samples.defaults, emptyList(), Samples.entertainment.id) + CategoryQuickRow(row, Samples.entertainment.id, {}, {}) + val frequent = quickRowCategories(Samples.defaults, listOf(Samples.groceries.id, Samples.restaurants.id, Samples.home.id, Samples.transport.id, Samples.health.id), Samples.shopping.id) + CategoryQuickRow(frequent, Samples.shopping.id, {}, {}) +} + +@Composable +fun CategoryGridSheetContent() { + CategoryGrid(Samples.defaults, Samples.groceries.id, {}) +} + +@Composable +fun ChipsSheet() { + EditorChipRow { + AccountChip("Vardag", 0, {}) + DateChip("2026-10-17", {}) + PayerChip(Samples.jacob, Samples.anna, {}) + SplitChip(defaultModeLabel(50), open = false, onClick = {}) + } + EditorChipRow { + DateChip("2026-10-16", {}) + DateChip("2026-10-14", {}) + PayerChip(Samples.anna, Samples.jacob, {}) + SplitChip(w("Exakta belopp", "Exact amounts"), open = true, onClick = {}) + } +} + +@Composable +fun AccountPickerSheet() { + val choices = listOf(Samples.vardagSummary, Samples.husetSummary, Samples.japanSummary, Samples.gotlandSummary).mapIndexed { i, s -> AccountChoice(s, i) } + AccountPicker(choices, Samples.anna, Samples.vardag.id, {}) +} + +@Composable +fun SplitBarsSheet() { + SplitBar(50f) + SplitBar(60f, thin = true, contentDescription = ratioSpoken(Samples.jacob, 6000, Samples.anna)) + SplitBar(70f, error = true) + SplitSlider(Samples.jacob, Samples.anna, 60, {}) + SplitPreview(Samples.ratioShares, Samples.jacob, Samples.anna) + SplitPreview(listOf(Share(Samples.jacob.id, 1_000_000, null), Share(Samples.anna.id, 234_567_89, null)), Samples.jacob, Samples.anna) +} + +@Composable +fun SplitSectionDefaultSheet() { + SplitSection(SplitMode.DEFAULT, defaultModeLabel(50), {}, {}) { + SplitBar(50f) + SplitPreview(Samples.ratioShares, Samples.jacob, Samples.anna) + } +} + +@Composable +fun SplitSectionRatioSheet() { + val language = kvittLanguage() + SplitSection(SplitMode.RATIO, defaultModeLabel(50), {}, {}) { + RatioEditor( + Samples.jacob, Samples.anna, 60, + viewerText = MoneyFormatter.basisPoints(6000, language), partnerText = MoneyFormatter.basisPoints(4000, language), + focused = RatioField.Viewer, onSlider = {}, onFocus = {}, onPreset = {}, + shares = listOf(Share(Samples.jacob.id, 24_720, 6000), Share(Samples.anna.id, 16_480, 4000)), + ) + } +} + +@Composable +fun SplitSectionExactSheet() { + val language = kvittLanguage() + SplitSection(SplitMode.EXACT, defaultModeLabel(50), {}, {}) { + ExactSplitEditor( + Samples.jacob, Samples.anna, typed = Samples.jacob.id, typedText = AmountInput("1200.00").display(language), derivedOre = null, + focused = true, amountOre = 106_000, typedOre = 120_000, onTypedChange = {}, onFocus = {}, + ) + } + SplitSection(SplitMode.EXACT, defaultModeLabel(50), {}, {}) { + ExactSplitEditor( + Samples.jacob, Samples.anna, typed = Samples.jacob.id, typedText = AmountInput("400").display(language), derivedOre = 66_000, + focused = false, amountOre = 106_000, typedOre = 40_000, onTypedChange = {}, onFocus = {}, + ) + } +} + +@Composable +fun SplitSectionFullSheet() { + SplitSection(SplitMode.FULL, defaultModeLabel(50), {}, {}) { + FullSplitPicker(Samples.jacob, Samples.anna, Samples.anna.id, {}, listOf(Share(Samples.jacob.id, 0, null), Share(Samples.anna.id, 106_000, null))) + } +} + +@Composable +fun CompactSummarySheet() { + CompactEditorSummary(Samples.shopping, "Ullared", 106_000, {}) + CompactEditorSummary(Samples.home, "Snickare Bergström, nytt golv i hallen och köket", 123_456_789, {}) +} + +@Composable +fun SettleSheet() { + DirectionCard(Samples.jacob, Samples.anna, DirectionWords(w("Riktning", "Direction"), w("Du betalar Anna", "You pay Anna"), w("Byt riktning", "Swap direction")), {}) + val language = kvittLanguage() + val lines = Samples.allocationAdjusted + SegmentedGroup(lines.size) { item -> + val line = lines[item.index] + val position = if (line.payerNetOre < 0) w("Skuld ${money(-line.payerNetOre)}", "Debt ${money(-line.payerNetOre)}") + else w("Anna är skyldig dig ${money(line.payerNetOre)}", "Anna owes you ${money(line.payerNetOre)}") + AllocationRow( + item, line, colorIndex = Samples.accounts.indexOf(line.account), amountText = if (line.amountOre == 0L) "" else MoneyFormatter.number(line.amountOre, language), + words = AllocationWords(position, w("Belopp för ${line.account.name}", "Amount for ${line.account.name}"), w("Ta bort ${line.account.name} från fördelningen", "Remove ${line.account.name} from the allocation"), w("Det här reglerar mer än som är skyldigt i ${line.account.name}", "This settles more than is owed in ${line.account.name}")), + onAmount = {}, onRemove = {}, + ) + } + SegmentedGroup(1) { item -> + val line = Samples.allocationProposal.first() + AllocationRow(item, line, 1, "", AllocationWords(w("Skuld 3 084,50 kr", "Debt SEK 3,084.50"), "", "", ""), {}, {}, fixed = true) + } + RemainingIndicator(0, w("Kvar att fördela", "Left to allocate")) + RemainingIndicator(25_000, w("Kvar att fördela", "Left to allocate")) + RemainingIndicator(-25_000, w("Kvar att fördela", "Left to allocate")) +} + +@Composable +fun TimelineSheet() { + Timeline(Samples.timeline) + Timeline(Samples.recurringTimeline) +} + +@Composable +fun chartGroups(): List { + val colors = KvittTheme.colors + return Samples.monthGroups.map { key -> + val category = Samples.defaults.first { it.id.value == key.id } + ChartGroup(key.id, colors.category(category.color), categoryName(category)) + } +} + +@Composable +fun StackedChartSheet() { + val language = kvittLanguage() + val groups = chartGroups() + val summary = w("Utgifter över tid", "Spending over time") + ChartCard(w("Utgifter över tid", "Spending over time")) { + StackedBarChart(Samples.monthBuckets, groups, summary, { "${DateFormatter.bucketLabel(it.key, language)}: ${MoneyFormatter.format(it.totalOre, language)}" }, {}) + ChartLegend(groups) + } +} + +@Composable +fun StackedChartVariantsSheet() { + val language = kvittLanguage() + val colors = KvittTheme.colors + val accounts = listOf(Samples.vardag, Samples.huset, Samples.japan) + val accountGroups = accounts.mapIndexed { i, a -> ChartGroup(a.id.value, colors.account(i), a.name) } + val start = LocalDate(2026, 8, 24) + val weeks = (0 until 8).map { i -> + val day = LocalDate.fromEpochDays(start.toEpochDays() + i * 7) + val totals = mapOf(Samples.vardag.id.value to 180_000L + i * 21_000L, Samples.huset.id.value to (if (i % 4 == 0) 620_000L else 40_000L), Samples.japan.id.value to (if (i == 6) 240_000L else 0L)).filterValues { it > 0 } + se.hagstedt.kvitt.model.SpendBucket(DateFormatter.isoWeekKey(day), day.toString(), day.toString(), totals, totals.values.sum()) + } + ChartCard(w("Utgifter över tid", "Spending over time")) { + StackedBarChart(weeks, accountGroups, "", { DateFormatter.bucketLabel(it.key, language) }, {}, xCaption = w("Vecka", "Week")) + ChartLegend(accountGroups) + } + val many = (1..12).map { m -> + val key = "2025-${m.toString().padStart(2, '0')}" + val totals = mapOf(Samples.groceries.id.value to 400_000L + m * 9_000L) + se.hagstedt.kvitt.model.SpendBucket(key, "$key-01", "$key-28", totals, totals.values.sum()) + } + (1..10).map { m -> + val key = "2026-${m.toString().padStart(2, '0')}" + val totals = mapOf(Samples.groceries.id.value to 480_000L + m * 7_000L) + se.hagstedt.kvitt.model.SpendBucket(key, "$key-01", "$key-28", totals, totals.values.sum()) + } + val one = listOf(ChartGroup(Samples.groceries.id.value, colors.category(Samples.groceries.color), categoryName(Samples.groceries))) + ChartCard(w("En grupp, 22 månader", "One group, 22 months")) { + StackedBarChart(many, one, "", { it.key }, {}) + } + val empty = listOf("2026-11").map { se.hagstedt.kvitt.model.SpendBucket(it, "$it-01", "$it-30", emptyMap(), 0) } + ChartCard(w("Tom period", "Empty period")) { + StackedBarChart(empty, one, "", { it.key }, {}) + } +} + +@Composable +fun BreakdownSheet() { + val colors = KvittTheme.colors + val categoryRows = Samples.byCategory.map { (key, ore) -> + val category = (Samples.defaults + Samples.pant).first { it.id.value == key.id } + BreakdownRow(key.id, categoryName(category), ore, colors.category(category.color)) { CategoryTile(category, KvittDimens.tileBreakdown) } + } + ChartCard(w("Per kategori", "By category")) { BreakdownList(categoryRows, 17_821_140L, {}) } + val accountRows = Samples.byAccount.mapIndexed { i, (key, ore) -> + val account = Samples.accounts.first { it.id.value == key.id } + BreakdownRow(key.id, account.name, ore, colors.account(i), archived = account.archived) { AccountTile(account.name, i, size = KvittDimens.tileBreakdown, muted = account.archived) } + } + ChartCard(w("Per konto", "By account")) { BreakdownList(accountRows, 17_821_140L, {}) } +} + +@Composable +fun FilterSheet() { + AnalyticsFilterBar( + w("Senaste 6 mån", "Last 6 months"), w("Period: senaste 6 månaderna", "Period: last 6 months"), {}, + FilterToggle(w("Indelning", "Interval"), listOf(w("Månad", "Month"), w("Vecka", "Week")), 0, {}), + FilterToggle(w("Mått", "Measure"), listOf(w("Hushållet", "Household"), w("Min del", "My share")), 0, {}), + FilterToggle(w("Gruppera efter", "Group by"), listOf(w("Kategori", "Category"), w("Konto", "Account")), 0, {}), + ) + TotalCard(17_821_140, w("Totalt", "Total"), w("maj–okt 2026 · Hushållet", "May–Oct 2026 · Household")) +} + +@Composable +fun TopBarSheet() { + KvittTopAppBar(null) + KvittTopAppBar("Vardag", navigation = TopBarNavigation.Back, actions = { + androidx.compose.material3.IconButton({}) { KvittIconImage(KvittIcon.MoreVert, w("Fler alternativ", "More options")) } + }) + KvittTopAppBar("Sommarstugan i Österåker 2027", navigation = TopBarNavigation.Back, scrolled = true) + KvittTopAppBar(w("Ny utgift", "New expense"), navigation = TopBarNavigation.Close, actions = { + KvittButton(w("Spara", "Save"), {}, icon = KvittIcon.Check) + }) +} + +@Composable +fun NavigationSheet() { + KvittNavigationBar(KvittDestination.Home, {}) + KvittNavigationBar(KvittDestination.Analytics, {}) +} + +@Composable +fun FabSheet() { + AddExpenseFab({}) + AddExpenseFab({}, expanded = false) + AddExpenseFab({}, text = w("Ny återkommande", "New recurring")) + AddExpenseFab({}, text = w("Ny återkommande", "New recurring"), enabled = false) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ButtonSheet() { + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittButton(w("Gör upp", "Settle up"), {}, icon = KvittIcon.Handshake, iconFilled = true) + KvittButton(w("Byt bild", "Change picture"), {}, kind = ButtonKind.Tonal, icon = KvittIcon.PhotoLibrary) + } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittButton(w("Uppdatera", "Update"), {}, kind = ButtonKind.Tertiary, icon = KvittIcon.Download) + KvittButton(w("Arkivera", "Archive"), {}, kind = ButtonKind.Outlined, icon = KvittIcon.Inventory2) + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittButton(w("Nytt konto", "New account"), {}, kind = ButtonKind.Text, icon = KvittIcon.Add) + KvittButton(w("Ta bort utgift", "Delete expense"), {}, kind = ButtonKind.DangerText, icon = KvittIcon.Delete) + KvittButton(w("Försök igen", "Try again"), {}, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Refresh) + } + KvittButton(w("Bekräfta", "Confirm"), {}, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Check) + KvittButton(w("Para ihop", "Pair"), {}, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Link, enabled = false) +} + +@Composable +fun BadgeSheet() { + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittBadge(w("Arkiverat", "Archived"), icon = KvittIcon.Inventory2) + KvittBadge(w("Återkommande: Hyra", "Recurring: Hyra"), kind = BadgeKind.Lantern, icon = KvittIcon.EventRepeat) + KvittBadge(w("Väntar på synk", "Waiting to sync"), kind = BadgeKind.Pending, icon = KvittIcon.CloudUpload) + } + SectionHeader(w("Konton", "Accounts"), action = w("Nytt konto", "New account"), actionIcon = KvittIcon.Add) + SectionHeader(w("Konton", "Accounts"), action = w("Nytt konto", "New account"), actionIcon = KvittIcon.Add, actionEnabled = false, actionSupporting = w("Kräver anslutning", "Needs connection")) + CollapsibleRow(w("Arkiverade (1)", "Archived (1)"), expanded = false, expandedState = w("utfälld", "expanded"), collapsedState = w("hopfälld", "collapsed"), onToggle = {}) + CollapsibleRow(w("Arkiverade (1)", "Archived (1)"), expanded = true, expandedState = w("utfälld", "expanded"), collapsedState = w("hopfälld", "collapsed"), onToggle = {}) +} + +@Composable +fun RowsSheet() { + SegmentedGroup(4) { item -> + when (item.index) { + 0 -> FactRow(item, KvittIcon.Event, w("Datum", "Date"), DateFormatter.detailDate("2026-10-16", kvittLanguage())) + 1 -> FactRow(item, KvittIcon.Person, w("Betalt av", "Paid by"), "Anna", valueLeading = { PersonAvatar(Samples.anna, KvittDimens.avatarChip) }) + 2 -> FactRow(item, KvittIcon.CallSplit, w("Delning", "Split"), defaultModeLabel(50), extra = { + SplitBar(50f, thin = true) + SplitPreview(Samples.icaShares, Samples.jacob, Samples.anna, showBar = false) + }) + else -> FactRow(item, KvittIcon.Notes, w("Anteckning", "Note"), "Storhandling inför middagen med Lindqvists") + } + } + SegmentedGroup(3) { item -> + when (item.index) { + 0 -> SettingsRow(item, KvittIcon.Person, ColorToken.ORANGE, w("Profil", "Profile"), {}, subtitle = w("Namn och bild", "Name and picture")) + 1 -> SettingsRow(item, KvittIcon.Category, ColorToken.GREEN, w("Kategorier", "Categories"), {}, subtitle = w("14 aktiva", "14 active")) + else -> SettingsRow(item, KvittIcon.Devices, ColorToken.BLUE, w("Enheter", "Devices"), {}) + } + } + SegmentedGroup(2, fill = GroupFill.Soft) { item -> + SettingsRow(item, if (item.index == 0) KvittIcon.Sync else KvittIcon.Info, if (item.index == 0) ColorToken.PURPLE else ColorToken.GREY, if (item.index == 0) w("Synkstatus", "Sync status") else w("Om Kvitt", "About Kvitt"), {}, subtitle = if (item.index == 0) w("Synkad för 3 min sedan", "Synced 3 min ago") else w("Version 1.1", "Version 1.1"), enabled = item.index == 0) + } +} + +@Composable +fun EmptyStateSheet() { + EmptyState( + w("Skapa ert första konto", "Create your first account"), + w("Ett konto samlar utgifter som ni delar på samma sätt, till exempel Vardag, Huset eller en resa. Ni kan ha hur många ni vill.", "An account collects expenses that you share the same way, for example Everyday, The house or a trip. You can have as many as you like."), + illustration = listOf(Samples.groceries, Samples.travel, Samples.restaurants), + action = w("Nytt konto", "New account"), actionIcon = KvittIcon.Add, + ) + EmptyState(w("Inga utgifter än", "No expenses yet"), w("Lägg till den första utgiften i Sommarstugan.", "Add the first expense in Sommarstugan.")) + EmptyState( + w("Inga utgifter i den här perioden", "No expenses in this period"), + w("Välj en annan period, eller lägg till en utgift med ett datum i perioden.", "Pick another period, or add an expense dated in the period."), + illustration = listOf(Samples.other, Samples.other, Samples.other), illustrationContainer = true, + action = w("Byt period", "Change period"), actionIcon = KvittIcon.DateRange, actionKind = ButtonKind.Tonal, + ) +} + +@Composable +fun DialogSheet() { + ConfirmDialogContent(KvittIcon.Delete, w("Ta bort utgiften?", "Delete the expense?"), w("Den försvinner för er båda.", "It disappears for both of you."), w("Ta bort", "Delete"), w("Avbryt", "Cancel"), {}, {}, destructive = true) + ConfirmDialogContent(KvittIcon.InstallMobile, w("Tillåt installation", "Allow installation"), w("Kvitt uppdateras direkt från er server, inte från Play Butik.", "Kvitt updates straight from your own server, not from the Play Store."), w("Fortsätt", "Continue"), w("Senare", "Later"), {}, {}, tertiaryTile = true, confirmIcon = KvittIcon.ArrowForward) +} + +@Composable +fun MenuSheet() { + KvittMenuContent( + listOf( + KvittMenuItem(KvittIcon.Edit, w("Ändra konto", "Edit account")), + KvittMenuItem(KvittIcon.Inventory2, w("Arkivera", "Archive")), + KvittMenuItem(KvittIcon.Delete, w("Ta bort konto", "Delete account"), enabled = false, disabledReason = w("Kontot kan inte tas bort så länge det har utgifter, betalningar eller återkommande utgifter", "The account cannot be deleted while it has expenses, payments or recurring expenses")), + ), + ) + KvittSnackbar(w("Utgiften sparades", "Expense saved")) + KvittSnackbar(w("Kunde inte nå servern", "Could not reach the server"), action = w("Försök igen", "Try again")) +} + +@Composable +fun InviteSheet() { + InviteCodeDisplay("7K3MQX9D2HPA") + InviteCodeField("7K3MQX9D2HPA", {}, w("Klistra in", "Paste"), {}) + InviteCodeField("7k3m-qx9", {}, w("Klistra in", "Paste"), {}, error = w("Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny.", "The code does not work. It may have expired or been used. Ask for a new one.")) +} + +@Composable +fun SkeletonSheet() { + Skeleton(KvittDimens.illustrationWidth, KvittDimens.buttonSmall, pill = true) + Skeleton(KvittDimens.menuMinWidth, KvittDimens.accountCardMin) + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + Skeleton(KvittDimens.tileLedger, KvittDimens.tileLedger) + Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + Skeleton(KvittDimens.menuMinWidth, KvittTheme.spacing.s4) + Skeleton(KvittDimens.illustrationWidth, KvittTheme.spacing.s3) + } + } +} + +/** Every sheet by its golden name, for the previews and the screenshot tests. */ +val CatalogueSheets: List Unit>> = listOf( + "money_text" to { MoneyTextSheet() }, + "effect_and_balance_words" to { WordsSheet() }, + "person_avatar" to { AvatarSheet() }, + "category_tile" to { CategoryTileSheet() }, + "account_tile" to { AccountTileSheet() }, + "account_card" to { AccountCardSheet() }, + "category_icon_picker" to { IconPickerSheet() }, + "color_swatch_picker" to { ColorSwatchSheet() }, + "ledger_rows" to { LedgerSheet() }, + "balance_hero_owing" to { BalanceHeroSheet(HeroState.Owing(325_000, Samples.anna)) }, + "balance_hero_owed" to { BalanceHeroSheet(HeroState.Owed(118_450, Samples.anna)) }, + "balance_hero_settled" to { BalanceHeroSheet(HeroState.Settled(Samples.anna)) }, + "balance_hero_waiting" to { BalanceHeroSheet(HeroState.Waiting) }, + "balance_hero_loading" to { BalanceHeroSheet(HeroState.Loading) }, + "balance_hero_long" to { BalanceHeroSheet(HeroState.Owed(123_456_789, Samples.annaLong)) }, + "account_balance_header" to { AccountHeaderSheet() }, + "sync_status_pill" to { SyncPillSheet() }, + "banners" to { BannerSheet() }, + "update_banner" to { UpdateBannerSheet() }, + "amount_display" to { AmountDisplaySheet() }, + "amount_keypad" to { KeypadSheet() }, + "text_fields" to { TextFieldSheet() }, + "category_quick_row" to { QuickRowSheet() }, + "category_grid" to { CategoryGridSheetContent() }, + "editor_chips" to { ChipsSheet() }, + "account_picker" to { AccountPickerSheet() }, + "split_bar_slider_preview" to { SplitBarsSheet() }, + "split_section_default" to { SplitSectionDefaultSheet() }, + "split_section_ratio" to { SplitSectionRatioSheet() }, + "split_section_exact" to { SplitSectionExactSheet() }, + "split_section_full" to { SplitSectionFullSheet() }, + "compact_editor_summary" to { CompactSummarySheet() }, + "settle_components" to { SettleSheet() }, + "timeline" to { TimelineSheet() }, + "stacked_bar_chart" to { StackedChartSheet() }, + "stacked_bar_chart_variants" to { StackedChartVariantsSheet() }, + "breakdown_list" to { BreakdownSheet() }, + "analytics_filter_and_total" to { FilterSheet() }, + "top_app_bar" to { TopBarSheet() }, + "navigation_bar" to { NavigationSheet() }, + "add_expense_fab" to { FabSheet() }, + "buttons" to { ButtonSheet() }, + "badges_headers_collapsible" to { BadgeSheet() }, + "fact_and_settings_rows" to { RowsSheet() }, + "empty_state" to { EmptyStateSheet() }, + "confirm_dialog" to { DialogSheet() }, + "menu_and_snackbar" to { MenuSheet() }, + "invite_code" to { InviteSheet() }, + "skeleton" to { SkeletonSheet() }, +) + +/** Sheets that also get a 200 % font-scale golden (Swedish, light): the spec's long states. */ +val LargeFontSheets: Set = setOf( + "money_text", "account_card", "ledger_rows", "balance_hero_owing", "balance_hero_long", "account_balance_header", "amount_keypad", + "category_quick_row", "editor_chips", "settle_components", "stacked_bar_chart", "breakdown_list", "analytics_filter_and_total", + "navigation_bar", "add_expense_fab", "sync_status_pill", +) + +@Composable +private fun CataloguePreview(name: String) { + KvittTheme { + ProvideHousehold(Samples.jacob, Samples.anna) { + Surface(color = MaterialTheme.colorScheme.surface) { + Column(Modifier.width(411.dp).padding(KvittTheme.spacing.gutter), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap)) { + CatalogueSheets.first { it.first == name }.second(this) + } + } + } + } +} + +@Preview(name = "Balance hero", locale = "sv") @Composable private fun PreviewHero() = CataloguePreview("balance_hero_owing") +@Preview(name = "Ledger", locale = "sv") @Composable private fun PreviewLedger() = CataloguePreview("ledger_rows") +@Preview(name = "Account cards", locale = "sv") @Composable private fun PreviewAccountCards() = CataloguePreview("account_card") +@Preview(name = "Editor chips", locale = "sv") @Composable private fun PreviewChips() = CataloguePreview("editor_chips") +@Preview(name = "Quick row", locale = "sv") @Composable private fun PreviewQuickRow() = CataloguePreview("category_quick_row") +@Preview(name = "Split ratio", locale = "sv") @Composable private fun PreviewRatio() = CataloguePreview("split_section_ratio") +@Preview(name = "Settle", locale = "sv") @Composable private fun PreviewSettle() = CataloguePreview("settle_components") +@Preview(name = "Chart", locale = "sv") @Composable private fun PreviewChart() = CataloguePreview("stacked_bar_chart") +@Preview(name = "Breakdown", locale = "en") @Composable private fun PreviewBreakdown() = CataloguePreview("breakdown_list") +@Preview(name = "Timeline", locale = "sv") @Composable private fun PreviewTimeline() = CataloguePreview("timeline") diff --git a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt new file mode 100644 index 0000000..ba6bb40 --- /dev/null +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Samples.kt @@ -0,0 +1,168 @@ +package se.hagstedt.kvitt.design.catalogue + +import se.hagstedt.kvitt.model.Account +import se.hagstedt.kvitt.model.AccountDetailState +import se.hagstedt.kvitt.model.AccountId +import se.hagstedt.kvitt.model.AccountSummary +import se.hagstedt.kvitt.model.AllocationLine +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.AuditSource +import se.hagstedt.kvitt.model.BalanceView +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.ExpenseEffect +import se.hagstedt.kvitt.model.ExpenseId +import se.hagstedt.kvitt.model.FieldChange +import se.hagstedt.kvitt.model.GroupKey +import se.hagstedt.kvitt.model.LedgerMonth +import se.hagstedt.kvitt.model.LedgerRow +import se.hagstedt.kvitt.model.Role +import se.hagstedt.kvitt.model.SettlementId +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SpendBucket +import se.hagstedt.kvitt.model.SyncSummary +import se.hagstedt.kvitt.model.TimelineLine +import se.hagstedt.kvitt.model.UpdateInfo +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId + +/** + * Realistic household data for the preview catalogue and the screenshot tests, as in the design spec's frames: + * Jacob (admin, the viewer) and Anna, the accounts Vardag, Huset, Japan 2027 and Gotland 2026 (archived), the 14 + * default categories and the custom "Pant". Ids are those of `:core:fakes` (`FakeScenarios`), which `:core:design` + * may not depend on (SPEC §6.2); debug source set only, so none of it reaches a release build. + */ +object Samples { + val jacob = User(UserId("01920000-0000-7000-8000-0000000000a1"), Role.ADMIN, "Jacob", null) + val anna = User(UserId("01920000-0000-7000-8000-0000000000a2"), Role.MEMBER, "Anna", null) + val annaLong = User(UserId("01920000-0000-7000-8000-0000000000a2"), Role.MEMBER, "Anna-Karin Lindqvist", null) + + private fun category(suffix: String, key: DefaultCategoryKey, icon: String, color: ColorToken, sort: Int) = + Category(CategoryId("01920000-0000-7000-8000-$suffix"), key, null, icon, color, sort, archived = false) + + val groceries = category("000000000001", DefaultCategoryKey.GROCERIES, "shopping_cart", ColorToken.GREEN, 10) + val restaurants = category("000000000002", DefaultCategoryKey.RESTAURANTS, "restaurant", ColorToken.ORANGE, 20) + val housing = category("000000000011", DefaultCategoryKey.HOUSING, "key", ColorToken.INDIGO, 25) + val home = category("000000000003", DefaultCategoryKey.HOME, "home", ColorToken.BROWN, 30) + val utilities = category("000000000004", DefaultCategoryKey.UTILITIES, "bolt", ColorToken.YELLOW, 40) + val insurance = category("000000000012", DefaultCategoryKey.INSURANCE, "shield", ColorToken.SLATE, 45) + val transport = category("000000000005", DefaultCategoryKey.TRANSPORT, "directions_car", ColorToken.BLUE, 50) + val travel = category("000000000006", DefaultCategoryKey.TRAVEL, "flight", ColorToken.TEAL, 60) + val entertainment = category("000000000007", DefaultCategoryKey.ENTERTAINMENT, "movie", ColorToken.PURPLE, 70) + val health = category("000000000008", DefaultCategoryKey.HEALTH, "medical_services", ColorToken.RED, 80) + val children = category("000000000013", DefaultCategoryKey.CHILDREN, "child_care", ColorToken.LIME, 85) + val shopping = category("000000000009", DefaultCategoryKey.SHOPPING, "shopping_bag", ColorToken.PINK, 90) + val fees = category("000000000014", DefaultCategoryKey.FEES, "account_balance", ColorToken.AMBER, 95) + val other = category("00000000000a", DefaultCategoryKey.OTHER, "category", ColorToken.GREY, 100) + val pant = Category(CategoryId("01920000-0000-7000-8000-0000000000c1"), null, "Pant", "water_drop", ColorToken.TEAL, 110, archived = true) + val renamedGroceries = groceries.copy(customName = "Matvaror") + + val defaults = listOf(groceries, restaurants, housing, home, utilities, insurance, transport, travel, entertainment, health, children, shopping, fees, other) + + private val shares5050 = mapOf(jacob.id to 50, anna.id to 50) + val vardag = Account(AccountId("01920000-0000-7000-8000-0000000000b1"), "Vardag", shares5050, "2026-01-04T09:00:00.000Z", archived = false) + val huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", mapOf(jacob.id to 60, anna.id to 40), "2026-01-05T09:00:00.000Z", archived = false) + val japan = Account(AccountId("01920000-0000-7000-8000-0000000000b3"), "Japan 2027", shares5050, "2026-03-01T09:00:00.000Z", archived = false) + val gotland = Account(AccountId("01920000-0000-7000-8000-0000000000b4"), "Gotland 2026", shares5050, "2026-05-20T09:00:00.000Z", archived = true) + val sommarstugan = Account(AccountId("01920000-0000-7000-8000-0000000000b5"), "Sommarstugan i Österåker 2027", mapOf(jacob.id to 60, anna.id to 40), "2026-10-17T09:00:00.000Z", archived = false) + val accounts = listOf(vardag, huset, japan, gotland) + + val vardagSummary = AccountSummary(vardag, 118_450, 11) + val husetSummary = AccountSummary(huset, -308_450, 4) + val japanSummary = AccountSummary(japan, -135_000, 2) + val gotlandSummary = AccountSummary(gotland, 0, 0) + val longSummary = AccountSummary(sommarstugan, -123_456_789, 1) + + val icaMaxi = LedgerRow.ExpenseRow(ExpenseId("e1"), "2026-10-16", "ICA Maxi Lindhagen", groceries, 124_760, ExpenseEffect.Borrowed(62_380), pending = false) + val systembolaget = LedgerRow.ExpenseRow(ExpenseId("e2"), "2026-10-15", "Systembolaget", entertainment, 41_200, ExpenseEffect.Lent(20_600), pending = false) + val slKort = LedgerRow.ExpenseRow(ExpenseId("e3"), "2026-10-14", "SL-kort 30 dagar, Anna", transport, 106_000, ExpenseEffect.Lent(106_000), pending = true) + val apotek = LedgerRow.ExpenseRow(ExpenseId("e4"), "2026-10-09", "Apotek Hjärtat", health, 18_990, ExpenseEffect.PaidForYourself, pending = false) + val babette = LedgerRow.ExpenseRow(ExpenseId("e5"), "2026-10-10", "Babette", restaurants, 118_000, ExpenseEffect.Borrowed(59_000), pending = false) + val longRow = LedgerRow.ExpenseRow(ExpenseId("e6"), "2025-09-28", "Snickare Bergström, nytt golv i hallen och köket", home, 123_456_789, ExpenseEffect.NotInvolved, pending = true) + val settlementFromAnna = LedgerRow.SettlementRow(SettlementId("s1"), "2026-10-06", anna, jacob, 80_000, accountCount = 2, pending = false) + val settlementToAnna = LedgerRow.SettlementRow(SettlementId("s2"), "2026-09-30", jacob, anna, 150_000, accountCount = 1, pending = true) + + val october = LedgerMonth("2026-10", 745_790, listOf(icaMaxi, systembolaget, slKort, babette, apotek, settlementFromAnna)) + val september2025 = LedgerMonth("2025-09", 1_234_567_89, listOf(longRow)) + + val vardagDetail = AccountDetailState(vardag, BalanceView(118_450, anna), listOf(october), deletable = false, users = listOf(jacob, anna)) + val gotlandDetail = AccountDetailState(gotland, BalanceView(0, anna), emptyList(), deletable = false, users = listOf(jacob, anna)) + val husetDetail = AccountDetailState(huset, BalanceView(-308_450, anna), emptyList(), deletable = false, users = listOf(jacob, anna)) + + val syncedSummary = SyncSummary("2026-10-17T11:59:00.000Z", pendingCount = 0, rejectedCount = 0, clockSkew = false, running = false) + val pendingSummary = syncedSummary.copy(pendingCount = 2) + val runningSummary = syncedSummary.copy(running = true) + val rejectedSummary = syncedSummary.copy(rejectedCount = 1) + val clockSummary = syncedSummary.copy(clockSkew = true) + val failedSummary = syncedSummary.copy(lastSuccessAt = "2026-10-17T09:30:00.000Z") + + val update = UpdateInfo(versionCode = 12, versionName = "1.2", sizeBytes = 8_400_000) + + val ratioShares = listOf(Share(jacob.id, 20_600, 5000), Share(anna.id, 20_600, 5000)) + val icaShares = listOf(Share(jacob.id, 62_380, 5000), Share(anna.id, 62_380, 5000)) + + val timeline = listOf( + TimelineLine(anna, AuditAction.CREATE, AuditSource.APP, "2026-10-16T12:02:00.000Z", emptyList(), pendingLocal = false), + TimelineLine(jacob, AuditAction.UPDATE, AuditSource.APP, "2026-10-16T17:40:00.000Z", listOf(FieldChange("amount_ore", "114760", "124760")), pendingLocal = false), + TimelineLine(anna, AuditAction.UPDATE, AuditSource.APP, "2026-10-16T18:05:00.000Z", listOf(FieldChange("category_id", "other", "groceries")), pendingLocal = false), + TimelineLine(jacob, AuditAction.UPDATE, AuditSource.APP, "2026-10-17T11:50:00.000Z", listOf(FieldChange("notes", null, "Storhandling inför middagen med Lindqvists")), pendingLocal = true), + ) + val recurringTimeline = listOf( + TimelineLine(null, AuditAction.CREATE, AuditSource.RECURRING, "2026-09-25T04:00:00.000Z", emptyList(), pendingLocal = false), + TimelineLine(anna, AuditAction.UPDATE, AuditSource.APP, "2026-09-26T08:15:00.000Z", listOf(FieldChange("expense_date", "2026-09-25", "2026-09-24"), FieldChange("description", "Hyra", "Hyra september")), pendingLocal = false), + TimelineLine(null, AuditAction.CREATE, AuditSource.IMPORT, "2026-10-01T10:00:00.000Z", emptyList(), pendingLocal = false), + TimelineLine(anna, AuditAction.DELETE, AuditSource.APP, "2026-10-17T07:12:00.000Z", emptyList(), pendingLocal = false), + ) + + val allocationProposal = listOf( + AllocationLine(huset, payerNetOre = -308_450, amountOre = 308_450, overSettles = false), + AllocationLine(japan, payerNetOre = -16_550, amountOre = 16_550, overSettles = false), + ) + val allocationAdjusted = listOf( + AllocationLine(huset, payerNetOre = -308_450, amountOre = 175_000, overSettles = false), + AllocationLine(japan, payerNetOre = -135_000, amountOre = 150_000, overSettles = true), + AllocationLine(vardag, payerNetOre = 118_450, amountOre = 0, overSettles = false), + ) + + val monthBuckets: List + val monthGroups: List = listOf(groceries, restaurants, housing, home, utilities, insurance, transport, travel, entertainment, health, shopping) + .map { GroupKey(it.id.value, null, it.color) } + + init { + val months = listOf("2026-05", "2026-06", "2026-07", "2026-08", "2026-09", "2026-10") + val travelByMonth = listOf(0L, 0L, 380_000L, 0L, 1_640_000L, 1_120_000L) + monthBuckets = months.mapIndexed { i, key -> + val totals = linkedMapOf( + groceries.id.value to 520_000L + i * 18_000L, + restaurants.id.value to 160_000L + i * 12_000L, + housing.id.value to 1_040_000L, + home.id.value to (if (i == 5) 320_000L else 70_000L), + utilities.id.value to 110_000L, + insurance.id.value to 64_000L, + transport.id.value to 106_000L, + travel.id.value to travelByMonth[i], + entertainment.id.value to 41_200L + i * 9_000L, + health.id.value to (if (i % 2 == 0) 18_990L else 52_000L), + shopping.id.value to (if (i == 1) 106_000L else 32_000L), + ).filterValues { it > 0 } + val days = if (key.endsWith("06") || key.endsWith("09")) "30" else "31" + SpendBucket(key, "$key-01", "$key-$days", totals, totals.values.sum()) + } + } + + val byCategory: List> = listOf( + GroupKey(housing.id.value, null, housing.color) to 6_200_000L, + GroupKey(groceries.id.value, null, groceries.color) to 3_673_000L, + GroupKey(travel.id.value, null, travel.color) to 3_140_000L, + GroupKey(restaurants.id.value, null, restaurants.color) to 1_140_000L, + GroupKey(pant.id.value, "Pant", pant.color) to 12_500L, + ) + val byAccount: List> = listOf( + GroupKey(huset.id.value, "Huset", null) to 9_150_000L, + GroupKey(vardag.id.value, "Vardag", null) to 5_480_000L, + GroupKey(japan.id.value, "Japan 2027", null) to 2_840_000L, + GroupKey(gotland.id.value, "Gotland 2026", null) to 351_140L, + ) +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt index 5f3822b..9a690fe 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/KvittTheme.kt @@ -2,15 +2,72 @@ package se.hagstedt.kvitt.design import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp +import se.hagstedt.kvitt.design.theme.KvittColors +import se.hagstedt.kvitt.design.theme.KvittDarkColorScheme +import se.hagstedt.kvitt.design.theme.KvittDarkColors +import se.hagstedt.kvitt.design.theme.KvittLightColorScheme +import se.hagstedt.kvitt.design.theme.KvittLightColors +import se.hagstedt.kvitt.design.theme.KvittMaterialShapes +import se.hagstedt.kvitt.design.theme.KvittMaterialTypography +import se.hagstedt.kvitt.design.theme.KvittShapes +import se.hagstedt.kvitt.design.theme.KvittSpacing +import se.hagstedt.kvitt.design.theme.KvittTypography +import se.hagstedt.kvitt.design.theme.LocalKvittColors +import se.hagstedt.kvitt.design.theme.LocalKvittShapes +import se.hagstedt.kvitt.design.theme.LocalKvittSpacing +import se.hagstedt.kvitt.design.theme.LocalKvittTypography + +/** Elevation of docs/ux/design-spec.md §2.10: tonal first; a soft card shadow in light only; level 3 for overlays. */ +object KvittElevation { + /** Content cards in light: `Modifier.shadow(2.dp, shape, ambientColor = shadow, spotColor = shadow)`; none in dark. */ + val card = 2.dp + + /** FAB, menus and dialogs (Material level 3). */ + val overlay = 6.dp +} /** - * Placeholder theme from M2b: Material 3 baseline colours, light and dark following the system (SPEC §13.1). - * M6 replaces the colour scheme, typography and shapes with the tokens of docs/ux/design-spec.md. + * The Kvitt theme ("Kalas", muted; design-spec §2). Follows the system light/dark setting; no dynamic colour (§2.6). + * Read the tokens through [KvittTheme.colors], [KvittTheme.typography], [KvittTheme.shapes] and [KvittTheme.spacing]; + * Material roles through `MaterialTheme`. */ @Composable fun KvittTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - MaterialTheme(colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(), content = content) + CompositionLocalProvider( + LocalKvittColors provides if (darkTheme) KvittDarkColors else KvittLightColors, + LocalKvittDark provides darkTheme, + LocalKvittTypography provides KvittTypography(), + LocalKvittShapes provides KvittShapes(), + LocalKvittSpacing provides KvittSpacing(), + ) { + MaterialTheme( + colorScheme = if (darkTheme) KvittDarkColorScheme else KvittLightColorScheme, + shapes = KvittMaterialShapes, + typography = KvittMaterialTypography, + content = content, + ) + } } + +/** Accessors for Kvitt's own tokens. */ +object KvittTheme { + val colors: KvittColors + @Composable @ReadOnlyComposable get() = LocalKvittColors.current + val typography: KvittTypography + @Composable @ReadOnlyComposable get() = LocalKvittTypography.current + val shapes: KvittShapes + @Composable @ReadOnlyComposable get() = LocalKvittShapes.current + val spacing: KvittSpacing + @Composable @ReadOnlyComposable get() = LocalKvittSpacing.current + + /** True in the dark theme (cards carry no shadow there, design-spec §2.10). */ + val isDark: Boolean + @Composable @ReadOnlyComposable get() = LocalKvittDark.current +} + +internal val LocalKvittDark = staticCompositionLocalOf { false } diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt new file mode 100644 index 0000000..87ec419 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt @@ -0,0 +1,368 @@ +package se.hagstedt.kvitt.design.chart + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +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.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.platform.LocalDensity +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.semantics.semantics +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.component.AmountStyle +import se.hagstedt.kvitt.design.component.AmountTone +import se.hagstedt.kvitt.design.component.ArchivedBadge +import se.hagstedt.kvitt.design.component.ConnectedButtonGroup +import se.hagstedt.kvitt.design.component.KvittCard +import se.hagstedt.kvitt.design.component.KvittIconImage +import se.hagstedt.kvitt.design.component.MoneyText +import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.spokenMoney +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.SpendBucket + +// The analytics charts of docs/ux/design-spec.md §3.10, drawn with Compose Canvas (chart decision in the M6 report: +// Vico draws stacked columns, but not the per-bar TalkBack nodes, 2 dp segment gaps and 10/4 corners the spec binds). + +/** A chart card (design-spec §3.10): padding 18 / 16 / 16, title title-medium. */ +@Composable +fun ChartCard(title: String, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + KvittCard( + modifier.fillMaxWidth(), + contentPadding = PaddingValues(start = KvittTheme.spacing.s4, end = KvittTheme.spacing.s4, top = KvittDimens.chartCardTop, bottom = KvittTheme.spacing.s4), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } +} + +/** A series of the stacked chart: group id, its colour (category colour or account chart colour) and legend label. */ +data class ChartGroup(val id: String, val color: Color, val label: String) + +/** Round gridline values for a maximum: 3–4 lines at 1, 1.5, 2, 2.5 or 5 × 10^n kronor. Returns the step in kronor. */ +fun niceStep(maxKronor: Long): Long { + if (maxKronor <= 0) return 1 + val raw = maxKronor / 3.0 + var magnitude = 1L + while (magnitude * 10 <= raw) magnitude *= 10 + val candidates = listOf(1.0, 1.5, 2.0, 2.5, 5.0, 10.0) + val factor = candidates.first { it * magnitude >= raw } + return (factor * magnitude).toLong().coerceAtLeast(1) +} + +/** + * Spending over time (design-spec §3.10): 170 dp plot, a 44 dp y gutter (wider when its labels need it, at 200 %) with gridlines at round whole-kronor values, + * one bar per bucket (empty buckets as zero), segments stacked in [groups] order with 2 dp gaps, top corners 10 and + * bottom 4; x labels under each bar (month or week number, [xCaption] "Vecka" under the axis). Bars narrower than + * 16 dp scroll horizontally, starting at the newest. Each bar is a focusable button read as [barDescription]; + * the plot's summary sentence is [summary]. + */ +@Composable +fun StackedBarChart( + buckets: List, + groups: List, + summary: String, + barDescription: (SpendBucket) -> String, + onBucket: (String) -> Unit, + modifier: Modifier = Modifier, + xCaption: String? = null, +) { + val language = kvittLanguage() + val typography = KvittTheme.typography + val colors = KvittTheme.colors + val maxKronor = (buckets.maxOfOrNull { it.totalOre } ?: 0L) / 100 + val step = niceStep(maxKronor) + val lines = if (maxKronor == 0L) 0L else ((maxKronor + step - 1) / step).coerceAtLeast(1) + val top = (step * lines).coerceAtLeast(1) + val measurer = rememberTextMeasurer() + val widest = (0..lines).maxOf { measurer.measure(MoneyFormatter.wholeKronor(step * it, language), typography.chartAxis).size.width } + val gutter = maxOf(KvittDimens.chartAxisGutter, with(LocalDensity.current) { widest.toDp() } + KvittTheme.spacing.s2) + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + Column(modifier.fillMaxWidth().semantics { contentDescription = summary }) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val plotWidth = maxWidth - gutter + val n = buckets.size.coerceAtLeast(1) + val natural = (plotWidth - KvittDimens.chartBarGap * (n - 1)) / n + val barWidth = natural.coerceIn(KvittDimens.chartMinBar, KvittDimens.chartMaxBar) + val scrolls = natural < KvittDimens.chartMinBar + val contentWidth = if (scrolls) (barWidth + KvittDimens.chartBarGap) * n else plotWidth + val scroll = rememberScrollState() + LaunchedEffect(scrolls, buckets.size) { if (scrolls) scroll.scrollTo(scroll.maxValue) } + val labelHeight = with(LocalDensity.current) { typography.chartAxis.lineHeight.toDp() } + Row { + Box(Modifier.width(gutter).height(KvittDimens.chartPlot).clearAndSetSemantics { }) { + for (i in 0..lines) { + val fraction = if (lines == 0L) 0f else i.toFloat() / lines + Text( + MoneyFormatter.wholeKronor(step * i, language), + Modifier + .width(gutter - KvittTheme.spacing.s2) + .offset(y = KvittDimens.chartPlot * (1 - fraction) - labelHeight / 2), + style = typography.chartAxis, + color = colors.chartAxisLabel, + textAlign = TextAlign.End, + maxLines = 1, + ) + } + } + Box(Modifier.weight(1f).then(if (scrolls) Modifier.horizontalScroll(scroll) else Modifier)) { + Column(Modifier.width(contentWidth)) { + Box(Modifier.width(contentWidth).height(KvittDimens.chartPlot)) { + Canvas(Modifier.width(contentWidth).height(KvittDimens.chartPlot).clearAndSetSemantics { }) { + val stroke = KvittDimens.chartGridLine.toPx() + for (i in 0..lines) { + val y = size.height * (1 - if (lines == 0L) 0f else i.toFloat() / lines) + drawLine(colors.chartGrid, Offset(0f, y.coerceIn(stroke, size.height - stroke)), Offset(size.width, y.coerceIn(stroke, size.height - stroke)), stroke) + } + } + Row(horizontalArrangement = Arrangement.spacedBy(if (scrolls) KvittDimens.chartBarGap else (contentWidth - barWidth * n) / (n.coerceAtLeast(2) - 1).coerceAtLeast(1))) { + buckets.forEach { bucket -> + val description = barDescription(bucket) + Bar(bucket, groups, top * 100, barWidth, Modifier + .clickable(role = Role.Button) { onBucket(bucket.key) } + .clearAndSetSemantics { contentDescription = description; role = Role.Button }) + } + } + } + Row( + Modifier.width(contentWidth).padding(top = KvittDimens.chartXLabelTop).clearAndSetSemantics { }, + horizontalArrangement = Arrangement.spacedBy(if (scrolls) KvittDimens.chartBarGap else (contentWidth - barWidth * n) / (n.coerceAtLeast(2) - 1).coerceAtLeast(1)), + ) { + val thin = large && barWidth < KvittDimens.chartMaxBar + buckets.forEachIndexed { index, bucket -> + Text( + if (thin && index % 2 == 1) "" else DateFormatter.bucketLabel(bucket.key, language), + Modifier.width(barWidth), + style = typography.chartXLabel, + color = colors.chartAxisLabel, + textAlign = TextAlign.Center, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Visible, + ) + } + } + } + } + } + } + if (xCaption != null) { + Text(xCaption, Modifier.fillMaxWidth().padding(start = gutter), style = typography.chartXLabel, color = colors.chartAxisLabel, textAlign = TextAlign.Center) + } + } +} + +@Composable +private fun Bar(bucket: SpendBucket, groups: List, topOre: Long, width: Dp, modifier: Modifier) { + val shapes = KvittTheme.shapes + Canvas(modifier.width(width).height(KvittDimens.chartPlot)) { + if (bucket.totalOre <= 0 || topOre <= 0) return@Canvas + val gap = KvittDimens.chartSegmentGap.toPx() + val total = size.height * bucket.totalOre / topOre + val clipTop = size.height - total + val outline = Path().apply { + addRoundRect( + RoundRect( + left = 0f, top = clipTop, right = size.width, bottom = size.height, + topLeftCornerRadius = CornerRadius(shapes.chartBarTopRadius.dp.toPx()), + topRightCornerRadius = CornerRadius(shapes.chartBarTopRadius.dp.toPx()), + bottomLeftCornerRadius = CornerRadius(shapes.chartBarBottomRadius.dp.toPx()), + bottomRightCornerRadius = CornerRadius(shapes.chartBarBottomRadius.dp.toPx()), + ), + ) + } + clipPath(outline) { + var bottom = size.height + groups.forEach { group -> + val ore = bucket.totals[group.id] ?: 0L + if (ore <= 0) return@forEach + val h = size.height * ore / topOre + drawRect(group.color, Offset(0f, bottom - h), Size(size.width, (h - gap).coerceAtLeast(0f))) + bottom -= h + } + } + } +} + +/** The legend (design-spec §3.10): wrapping row, 10 dp swatches (radius 3) and labels; decorative for TalkBack. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ChartLegend(groups: List, modifier: Modifier = Modifier) { + FlowRow( + modifier.fillMaxWidth().clearAndSetSemantics { }, + horizontalArrangement = Arrangement.spacedBy(KvittDimens.legendItemGap), + verticalArrangement = Arrangement.spacedBy(KvittDimens.legendRowGap), + ) { + groups.forEach { group -> + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + Box(Modifier.size(KvittDimens.legendSwatch).background(group.color, KvittTheme.shapes.legendSwatch)) + Text(group.label, style = KvittTheme.typography.chartLegend, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** A row of [BreakdownList]: id, name, amount, its colour, its tile, archived badge. */ +data class BreakdownRow(val id: String, val name: String, val amountOre: Long, val color: Color, val archived: Boolean = false, val tile: @Composable () -> Unit) + +/** + * Breakdown by category or account (design-spec §3.10, AC-14.6): rows by amount descending; tile 32, name, amount + * over the percent (one decimal, half-up, AC-14.10), and a 10 dp bar indented 44 dp filled to amount / largest. + * Each row is one node "Boende, 62 000 kronor, 34,8 procent". + */ +@Composable +fun BreakdownList(rows: List, totalOre: Long, onRow: (String) -> Unit, modifier: Modifier = Modifier) { + val language = kvittLanguage() + val sorted = rows.sortedByDescending { it.amountOre } + val largest = sorted.firstOrNull()?.amountOre ?: 0L + val separator = stringResource(R.string.common_separator_comma) + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + sorted.forEach { row -> + val percentNumber = MoneyFormatter.percent(row.amountOre, totalOre, language) + val percent = stringResource(R.string.common_percent, percentNumber) + val spoken = listOf(row.name, spokenMoney(row.amountOre), percent).joinToString(separator) + Column( + Modifier + .fillMaxWidth() + .clip(KvittTheme.shapes.strip) + .clickable(role = Role.Button) { onRow(row.id) } + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button } + .padding(vertical = KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittDimens.bannerGap), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + row.tile() + Row(Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + Text(row.name, Modifier.weight(1f, fill = false), style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (row.archived) ArchivedBadge() + } + Column(horizontalAlignment = Alignment.End) { + MoneyText(row.amountOre, AmountStyle.Small, AmountTone.Neutral, textAlign = TextAlign.End) + Text(percent, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + val fraction = if (largest <= 0) 0f else row.amountOre.toFloat() / largest + Box( + Modifier + .padding(start = KvittDimens.breakdownIndent) + .fillMaxWidth() + .height(KvittDimens.breakdownBar) + .background(MaterialTheme.colorScheme.surfaceContainerHighest, CircleShape), + ) { + Box(Modifier.fillMaxWidth(fraction).height(KvittDimens.breakdownBar).background(row.color, CircleShape)) + } + } + } + } +} + +/** One connected toggle of the filter bar: its radio-group name, options and selection. */ +data class FilterToggle(val groupLabel: String, val options: List, val selected: Int, val onSelect: (Int) -> Unit) + +/** + * The analytics filters (design-spec §3.10): period chip ([period] "Senaste 6 mån", TalkBack [periodSpoken]) with + * [granularity] "Månad | Vecka"; then [measure] and [groupBy] at half width each. At ≥ 1.5× one control per row. + */ +@Composable +fun AnalyticsFilterBar( + period: String, + periodSpoken: String, + onPeriod: () -> Unit, + granularity: FilterToggle, + measure: FilterToggle, + groupBy: FilterToggle, + modifier: Modifier = Modifier, +) { + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + val chip: @Composable (Modifier) -> Unit = { m -> + Row( + m + .heightIn(min = KvittDimens.filterButtonMin) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .clickable(role = Role.Button, onClick = onPeriod) + .clearAndSetSemantics { contentDescription = periodSpoken; role = Role.Button } + .padding(horizontal = KvittTheme.spacing.s4), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + KvittIconImage(KvittIcon.DateRange, null, size = KvittDimens.iconChip) + Text(period, style = KvittTheme.typography.chip, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false)) + KvittIconImage(KvittIcon.ExpandMore, null, size = KvittDimens.iconChip) + } + } + val toggle: @Composable (FilterToggle, Modifier) -> Unit = { t, m -> ConnectedButtonGroup(t.options, t.selected, t.onSelect, t.groupLabel, m) } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + if (large) { + chip(Modifier) + toggle(granularity, Modifier.fillMaxWidth()) + toggle(measure, Modifier.fillMaxWidth()) + toggle(groupBy, Modifier.fillMaxWidth()) + } else { + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { + chip(Modifier.weight(1f)) + toggle(granularity, Modifier.weight(1f).width(KvittDimens.filterToggleMinWidth)) + } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + toggle(measure, Modifier.weight(1f)) + toggle(groupBy, Modifier.weight(1f)) + } + } + } +} + +/** The total card (design-spec §3.10): "Totalt" ([label]) over `MoneyText(Large)` over the range [caption]. */ +@Composable +fun TotalCard(totalOre: Long, label: String, caption: String, modifier: Modifier = Modifier) { + KvittCard(modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = KvittDimens.cardPaddingLarge, vertical = KvittDimens.cardPadding)) { + Text(label, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + MoneyText(totalOre, AmountStyle.Large, AmountTone.Neutral) + Spacer(Modifier.height(KvittTheme.spacing.s1)) + Text(caption, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Balance.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Balance.kt new file mode 100644 index 0000000..5a5f575 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Balance.kt @@ -0,0 +1,370 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.Canvas +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.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.BuntingTokens +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.design.theme.KvittFontCaps +import se.hagstedt.kvitt.design.theme.KvittMotion +import se.hagstedt.kvitt.design.theme.capped +import se.hagstedt.kvitt.model.AccountDetailState +import se.hagstedt.kvitt.model.BalanceView +import se.hagstedt.kvitt.model.User +import kotlin.math.cos +import kotlin.math.sin + +// Balance components of docs/ux/design-spec.md §3.6: the Home hero with the seesaw, and the account header of S-5. + +/** The states of the balance hero (design-spec §3.6). */ +sealed interface HeroState { + /** The partner owes the viewer [netOre] (> 0). */ + data class Owed(val netOre: Long, val partner: User) : HeroState + + /** The viewer owes the partner [owedOre] (> 0). */ + data class Owing(val owedOre: Long, val partner: User) : HeroState + + data class Settled(val partner: User) : HeroState + + /** S-3: the partner has not joined; the seat is empty. */ + data object Waiting : HeroState + + /** S-4 first sync. */ + data object Loading : HeroState + + companion object { + /** From Home's global balance: null while the first sync runs. */ + fun of(view: BalanceView?): HeroState = when { + view == null -> Loading + view.netOre > 0 -> Owed(view.netOre, view.partner) + view.netOre < 0 -> Owing(-view.netOre, view.partner) + else -> Settled(view.partner) + } + } +} + +/** + * Words the hero takes from its feature (`home_*`, `onboarding_*`): the settle button, the loading lines, and for + * [HeroState.Waiting] the title, body, partner label and empty-seat description. + */ +data class HeroWords( + val settleUp: String, + val loadingTitle: String = "", + val loadingBody: String = "", + val waitingTitle: String = "", + val waitingBody: String = "", + val waitingPartnerLabel: String = "", + val waitingSeatDescription: String = "", +) + +/** + * The Home hero (design-spec §3.6): card radius 28 with the seesaw, the wording and the hero amount, and "Gör upp". + * Settled: `settled-container`, bunting, level plank, "Ni är kvitt". The card is one TalkBack node; the button is + * separate. [waitingAction] holds S-3's invite button and its hint. + */ +@Composable +fun BalanceHero( + state: HeroState, + me: User, + words: HeroWords, + onSettle: () -> Unit, + modifier: Modifier = Modifier, + waitingAction: (@Composable () -> Unit)? = null, +) { + val colors = KvittTheme.colors + val settled = state is HeroState.Settled + val padding = when (state) { + is HeroState.Settled -> PaddingValues(start = KvittDimens.heroSide, end = KvittDimens.heroSide, top = KvittDimens.heroSettledTop, bottom = KvittDimens.heroBottom) + HeroState.Loading -> PaddingValues(horizontal = KvittDimens.heroSide, vertical = KvittDimens.loadingVertical) + else -> PaddingValues(start = KvittDimens.heroSide, end = KvittDimens.heroSide, top = KvittDimens.heroTop, bottom = KvittDimens.heroBottom) + } + Box(modifier.fillMaxWidth()) { + KvittCard( + Modifier.fillMaxWidth(), + color = if (settled) colors.settledContainer else colors.card, + contentPadding = padding, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + when (state) { + HeroState.Loading -> HeroLoading(words) + HeroState.Waiting -> { + val spoken = words.waitingTitle + ". " + words.waitingBody + Column(Modifier.clearAndSetSemantics { contentDescription = spoken }, horizontalAlignment = Alignment.CenterHorizontally) { + Seesaw(me, null, SeesawTilt.Waiting, words.waitingPartnerLabel, words.waitingSeatDescription) + Spacer(Modifier.height(KvittTheme.spacing.s3)) + Text(words.waitingTitle, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Spacer(Modifier.height(KvittTheme.spacing.s2)) + Text(words.waitingBody, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + if (waitingAction != null) { + Spacer(Modifier.height(KvittDimens.buttonGapAbove)) + waitingAction() + } + } + is HeroState.Settled -> { + val title = stringResource(R.string.balance_all_settled) + Column(Modifier.clearAndSetSemantics { contentDescription = title }, horizontalAlignment = Alignment.CenterHorizontally) { + Seesaw(me, state.partner, SeesawTilt.Level, null, null) + Spacer(Modifier.height(KvittTheme.spacing.s3)) + Text(title, style = MaterialTheme.typography.headlineMedium, color = colors.onSettledContainer, textAlign = TextAlign.Center) + Text(stringResource(R.string.balance_all_settled_body), style = MaterialTheme.typography.bodyMedium, color = colors.onSettledContainer, textAlign = TextAlign.Center) + } + } + is HeroState.Owed -> HeroBalance(me, state.partner, state.netOre, SeesawTilt.PartnerLower, words.settleUp, onSettle) + is HeroState.Owing -> HeroBalance(me, state.partner, -state.owedOre, SeesawTilt.ViewerLower, words.settleUp, onSettle) + } + } + if (settled) Bunting(Modifier.fillMaxWidth().height(KvittDimens.buntingHeight).align(Alignment.TopCenter)) + } +} + +@Composable +private fun HeroBalance(me: User, partner: User, netOre: Long, tilt: SeesawTilt, settleLabel: String, onSettle: () -> Unit) { + val tone = toneOf(netOre) + val spoken = balanceSpoken(netOre, partner, BalanceScope.Global) + Column(Modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, horizontalAlignment = Alignment.CenterHorizontally) { + Seesaw(me, partner, tilt, null, null) + Spacer(Modifier.height(KvittTheme.spacing.s3)) + Text(balanceWording(netOre, partner, BalanceScope.Global), style = MaterialTheme.typography.titleMedium, color = tone.color(), textAlign = TextAlign.Center) + MoneyText(netOre, AmountStyle.Hero, tone, textAlign = TextAlign.Center) + } + Spacer(Modifier.height(KvittDimens.buttonGapAbove)) + KvittButton(settleLabel, onSettle, icon = KvittIcon.Handshake, iconFilled = true) +} + +@Composable +private fun HeroLoading(words: HeroWords) { + Column(Modifier.semantics(mergeDescendants = true) {}, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + // LoadingIndicator (shape morph) is not in Material 3 1.4.0: stable fallback CircularProgressIndicator (§3.1). + Box(Modifier.size(KvittDimens.loadingTile).background(MaterialTheme.colorScheme.primaryContainer, KvittTheme.shapes.illustration), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(KvittDimens.loadingIndicator - KvittTheme.spacing.s4), color = MaterialTheme.colorScheme.onPrimaryContainer) + } + Text(words.loadingTitle, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) + Text(words.loadingBody, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } +} + +private enum class SeesawTilt { ViewerLower, PartnerLower, Level, Waiting } + +/** + * The seesaw (decorative, 250 × 92 dp): plank 220 × 8 on a fulcrum 36 × 22 in `outline-variant`; the viewer on the + * left, the partner on the right, 88 dp from the centre; the side of the person who owes sits lower (9°). + */ +@Composable +private fun Seesaw(me: User, partner: User?, tilt: SeesawTilt, partnerLabel: String?, seatDescription: String?) { + val colors = KvittTheme.colors + val plankColor = when (tilt) { + SeesawTilt.ViewerLower -> colors.owing + SeesawTilt.PartnerLower -> colors.owed + SeesawTilt.Level -> colors.settled + SeesawTilt.Waiting -> MaterialTheme.colorScheme.outline + } + val target = when (tilt) { + SeesawTilt.ViewerLower -> -KvittDimens.SEESAW_TILT_DEGREES + SeesawTilt.PartnerLower -> KvittDimens.SEESAW_TILT_DEGREES + SeesawTilt.Level -> 0f + SeesawTilt.Waiting -> -KvittDimens.SEESAW_WAITING_DEGREES + } + val degrees by animateFloatAsState(target, KvittMotion.seesaw(), label = "seesaw") + val fulcrum = MaterialTheme.colorScheme.outlineVariant + val nameStyle = KvittTheme.typography.seesawName.capped(KvittFontCaps.SEESAW_NAME) + val nameColor = MaterialTheme.colorScheme.onSurfaceVariant + val avatar = KvittDimens.avatarSeesaw + val gap = KvittTheme.spacing.s2 + Layout( + modifier = Modifier.fillMaxWidth().height(KvittDimens.seesawHeight).clearAndSetSemantics { }, + contents = listOf( + { + Canvas(Modifier.fillMaxWidth().height(KvittDimens.seesawHeight)) { + val cx = size.width / 2 + val pivotY = size.height - KvittDimens.fulcrumHeight.toPx() + val fw = KvittDimens.fulcrumWidth.toPx() + val triangle = Path().apply { + moveTo(cx, pivotY) + lineTo(cx - fw / 2, size.height) + lineTo(cx + fw / 2, size.height) + close() + } + drawPath(triangle, fulcrum) + val length = KvittDimens.plankLength.toPx() + val thick = KvittDimens.plankThickness.toPx() + rotate(degrees, Offset(cx, pivotY - thick / 2)) { + drawRoundRect(plankColor, Offset(cx - length / 2, pivotY - thick), Size(length, thick), CornerRadius(thick / 2)) + } + } + }, + { PersonAvatar(me, avatar, shape = AvatarShape.Large) }, + { + if (partner != null) PersonAvatar(partner, avatar, shape = AvatarShape.Large) else AvatarPlaceholder(avatar, contentDescription = seatDescription) + }, + { Text(stringResource(R.string.common_you), style = nameStyle, color = nameColor, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + { Text(partner?.displayName ?: partnerLabel.orEmpty(), style = nameStyle, color = nameColor, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + ), + ) { measurables, constraints -> + val loose = constraints.copy(minWidth = 0, minHeight = 0) + val canvas = measurables[0].first().measure(constraints) + val left = measurables[1].first().measure(loose) + val right = measurables[2].first().measure(loose) + val width = constraints.maxWidth + val nameRoom = (width / 2 - KvittDimens.seesawArm.roundToPx() - left.width / 2 - gap.roundToPx()).coerceAtLeast(0) + val leftName = measurables[3].first().measure(loose.copy(maxWidth = nameRoom)) + val rightName = measurables[4].first().measure(loose.copy(maxWidth = nameRoom)) + val height = canvas.height + layout(width, height) { + canvas.place(0, 0) + val cx = width / 2f + val pivotY = height - KvittDimens.fulcrumHeight.toPx() - KvittDimens.plankThickness.toPx() / 2 + val arm = KvittDimens.seesawArm.toPx() + val radians = Math.toRadians(degrees.toDouble()) + val lift = KvittDimens.plankThickness.toPx() / 2 + left.height / 2f + fun seat(side: Int): Pair { + val x = cx + side * arm * cos(radians).toFloat() + val y = pivotY + side * arm * sin(radians).toFloat() - lift + return x to y + } + val (lx, ly) = seat(-1) + val (rx, ry) = seat(1) + left.place((lx - left.width / 2).toInt(), (ly - left.height / 2).toInt()) + right.place((rx - right.width / 2).toInt(), (ry - right.height / 2).toInt()) + leftName.place((lx - left.width / 2 - gap.toPx() - leftName.width).toInt(), (ly - leftName.height / 2).toInt()) + rightName.place((rx + right.width / 2 + gap.toPx()).toInt(), (ry - rightName.height / 2).toInt()) + } + } +} + +/** The settled bunting: a curved `outline` line with 14 pennants in category colours (design-spec §3.6). */ +@Composable +private fun Bunting(modifier: Modifier) { + val colors = KvittTheme.colors + val line = MaterialTheme.colorScheme.outline + val pennants: List = BuntingTokens.map { colors.category(it) } + Canvas(modifier.clearAndSetSemantics { }) { + val inset = size.height * 0.4f + val sag = size.height * 0.25f + val top = size.height * 0.15f + fun yAt(x: Float): Float { + val t = (x - inset) / (size.width - 2 * inset) + return top + 4 * sag * t * (1 - t) + } + val path = Path().apply { + moveTo(inset, top) + quadraticTo(size.width / 2, top + 2 * sag, size.width - inset, top) + } + drawPath(path, line, style = Stroke(width = KvittDimens.buntingLine.toPx())) + val count = pennants.size + val step = (size.width - 2 * inset) / count + pennants.forEachIndexed { i, color -> + val x0 = inset + i * step + step * 0.12f + val x1 = inset + (i + 1) * step - step * 0.12f + val mid = (x0 + x1) / 2 + val tri = Path().apply { + moveTo(x0, yAt(x0)) + lineTo(x1, yAt(x1)) + lineTo(mid, yAt(mid) + size.height * 0.55f) + close() + } + drawPath(tri, color) + } + } +} + +/** Words of the account header that come from `:feature:account` (`account_default_split`, actions). */ +data class AccountHeaderWords( + /** "50 / 50 · Jacob och Anna". */ + val defaultSplit: String, + /** "standarddelning 50 procent du, 50 procent Anna" for TalkBack. */ + val defaultSplitSpoken: String, + val settleAccount: String, + val unarchive: String, +) + +/** + * The S-5 header (design-spec §3.6): `account-header-n` fill, tile, default split with a thin split bar, "Arkiverat", + * the balance wording over `MoneyText(Large)` (zero: check + "Kvitt"), "Gör upp det här kontot" when not settled + * (archived too) and "Återställ från arkiv" first for an archived account. + */ +@Composable +fun AccountBalanceHeader( + detail: AccountDetailState, + colorIndex: Int, + viewerPercent: Float, + words: AccountHeaderWords, + onSettle: () -> Unit, + onUnarchive: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = KvittTheme.colors + val net = detail.balance.netOre + val partner = detail.balance.partner + val separator = stringResource(R.string.common_separator_comma) + val spoken = listOf(detail.account.name, words.defaultSplitSpoken, balanceSpoken(net, partner, BalanceScope.Account)).joinToString(separator) + KvittCard( + modifier.fillMaxWidth(), + color = colors.accountHeader(colorIndex), + contentPadding = PaddingValues(start = KvittDimens.heroSide, end = KvittDimens.heroSide, top = KvittDimens.accountHeaderTop, bottom = KvittDimens.heroBottom), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), + ) { + Column(Modifier.clearAndSetSemantics { contentDescription = spoken }, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap)) { + AccountTile(detail.account.name, colorIndex, size = KvittDimens.tileLedger, muted = detail.account.archived) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + Text(words.defaultSplit, style = MaterialTheme.typography.titleMedium) + SplitBar(viewerPercent, thin = true) + } + if (detail.account.archived) ArchivedBadge() + } + if (net == 0L) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittIconImage(KvittIcon.CheckCircle, null, filled = true, size = KvittDimens.accountHeaderSettledIcon, tint = colors.settled) + Text(stringResource(R.string.balance_account_settled), style = MaterialTheme.typography.headlineSmall, color = colors.settled) + } + } else { + Column { + BalanceWording(net, partner, BalanceScope.Account) + MoneyText(net, AmountStyle.Large, toneOf(net)) + } + } + } + if (detail.account.archived || net != 0L) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + if (detail.account.archived) KvittButton(words.unarchive, onUnarchive, kind = ButtonKind.Tonal, icon = KvittIcon.Unarchive) + if (net != 0L) KvittButton(words.settleAccount, onSettle, icon = KvittIcon.Handshake, iconFilled = true) + } + } + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Categories.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Categories.kt new file mode 100644 index 0000000..f6e89ad --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Categories.kt @@ -0,0 +1,286 @@ +package se.hagstedt.kvitt.design.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.PaddingValues +import androidx.compose.foundation.layout.Row +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.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +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.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +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.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.icon.CategoryIconRes +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.design.theme.KvittFontCaps +import se.hagstedt.kvitt.design.theme.capped +import se.hagstedt.kvitt.model.AccountSummary +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryIcons +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.User + +/** Fill of a category tile: solid colour with `on-cat` icon, or container with the colour as icon (design-spec §2.3). */ +enum class TileStyle { Solid, Container } + +/** + * A category's tile (design-spec §3.4): category shape, filled icon at 52 % of the size. Archived: saturation 25 %, + * opacity 75 %. Decorative; the name is always printed beside it. + */ +@Composable +fun CategoryTile(category: Category, size: Dp, modifier: Modifier = Modifier, style: TileStyle = TileStyle.Solid, archived: Boolean = category.archived) { + CategoryTokenTile(category.icon, category.color, size, modifier, style, archived) +} + +/** A tile from an icon token and colour token, for the live preview of the category editor (S-15). */ +@Composable +fun CategoryTokenTile(icon: String, color: ColorToken, size: Dp, modifier: Modifier = Modifier, style: TileStyle = TileStyle.Solid, archived: Boolean = false) { + val colors = KvittTheme.colors + var fill = if (style == TileStyle.Solid) colors.category(color) else colors.categoryContainer(color) + var tint = if (style == TileStyle.Solid) colors.onCat else colors.category(color) + if (archived) { + fill = fill.desaturate(KvittDimens.ARCHIVED_SATURATION) + tint = tint.desaturate(KvittDimens.ARCHIVED_SATURATION) + } + Box( + modifier + .size(size) + .alpha(if (archived) KvittDimens.ARCHIVED_ALPHA else 1f) + .background(fill, KvittTheme.shapes.categoryTile) + .clearAndSetSemantics { }, + contentAlignment = Alignment.Center, + ) { + Icon(painterResource(CategoryIconRes.drawable(icon)), null, Modifier.size(size * KvittDimens.TILE_ICON_RATIO), tint = tint) + } +} + +/** + * An account's tile (design-spec §3.4): account shape in `chart-cat-n`, the first grapheme of the name uppercase in + * `on-cat` (18 sp 700, capped 1.3×). Muted (archived): opacity 70 %. Always decorative. + */ +@Composable +fun AccountTile(name: String, colorIndex: Int, modifier: Modifier = Modifier, size: Dp = KvittDimens.accountTile, muted: Boolean = false) { + val colors = KvittTheme.colors + val letter = firstGrapheme(name).uppercase() + val base = KvittTheme.typography.accountTileLetter + val scaled = base.copy(fontSize = base.fontSize * (size / KvittDimens.accountTile), lineHeight = base.lineHeight * (size / KvittDimens.accountTile)) + Box( + modifier + .size(size) + .alpha(if (muted) KvittDimens.ACCOUNT_TILE_MUTED_ALPHA else 1f) + .background(colors.account(colorIndex), KvittTheme.shapes.accountTile) + .clearAndSetSemantics { }, + contentAlignment = Alignment.Center, + ) { + Text(letter, color = colors.onCat, style = scaled.capped(KvittFontCaps.ACCOUNT_TILE_LETTER), maxLines = 1) + } +} + +/** The first user-perceived character of [text] (a surrogate pair or combining mark stays whole). */ +fun firstGrapheme(text: String): String { + val trimmed = text.trim() + if (trimmed.isEmpty()) return "" + var end = trimmed.offsetByCodePoints(0, 1) + while (end < trimmed.length && Character.getType(trimmed.codePointAt(end)).let { it == Character.NON_SPACING_MARK.toInt() || it == Character.ENCLOSING_MARK.toInt() }) { + end = trimmed.offsetByCodePoints(end, 1) + } + return trimmed.substring(0, end) +} + +/** + * Home's account card (design-spec §3.4): tile 44, name with "Arkiverat" badge, "11 utgifter i oktober" (caller's + * resolved [expensesLine], a feature string), and the balance wording with `MoneyText(Medium)` or "Kvitt" with a + * check. One node "Vardag, Anna är skyldig dig 1 184,50 kronor, 11 utgifter i oktober", role button. + */ +@Composable +fun AccountCard( + summary: AccountSummary, + partner: User, + colorIndex: Int, + expensesLine: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val archived = summary.account.archived + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + val archivedWord = stringResource(R.string.common_archived).lowercase() + val spoken = listOfNotNull( + summary.account.name, + balanceSpoken(summary.netOre, partner, BalanceScope.Account), + expensesLine, + if (archived) archivedWord else null, + ).joinToString(stringResource(R.string.common_separator_comma)) + KvittCard( + modifier + .fillMaxWidth() + .alpha(if (archived) KvittDimens.ACCOUNT_CARD_ARCHIVED_ALPHA else 1f) + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button }, + shape = KvittTheme.shapes.accountCard, + contentPadding = PaddingValues(horizontal = KvittTheme.spacing.s4, vertical = KvittDimens.accountCardVertical), + ) { + Row(Modifier.heightIn(min = KvittDimens.accountCardMin - KvittDimens.accountCardVertical * 2), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap)) { + AccountTile(summary.account.name, colorIndex, muted = archived) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + Text( + summary.account.name, + Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.titleMedium, + maxLines = if (large) 2 else 1, + overflow = TextOverflow.Ellipsis, + ) + if (archived) ArchivedBadge() + } + Text(expensesLine, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (large) AccountBalanceColumn(summary.netOre, partner, Modifier.align(Alignment.End).padding(top = KvittTheme.spacing.s2)) + } + if (!large) AccountBalanceColumn(summary.netOre, partner) + } + } +} + +@Composable +private fun AccountBalanceColumn(netOre: Long, partner: User, modifier: Modifier = Modifier) { + if (netOre == 0L) { + Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittIconImage(KvittIcon.CheckCircle, null, filled = true, size = KvittDimens.accountCardSettledIcon, tint = KvittTheme.colors.settled) + Text(stringResource(R.string.balance_account_settled), style = MaterialTheme.typography.titleMedium, color = KvittTheme.colors.settled) + } + } else { + Column(modifier, horizontalAlignment = Alignment.End) { + Text(balanceWording(netOre, partner, BalanceScope.Account), style = MaterialTheme.typography.labelMedium, color = toneOf(netOre).color(), textAlign = TextAlign.End) + MoneyText(netOre, AmountStyle.Medium, toneOf(netOre), textAlign = TextAlign.End) + } + } +} + +/** + * The 33 icon tokens in a 7-column grid of 48 dp cells (design-spec §3.4, S-15). Unselected: `surface-container-high` + * with the outline-coloured icon; selected: the category colour with `on-cat`. A radio group; each cell reads its + * icon name and "vald" ([selectedState]). + */ +@Composable +fun CategoryIconPicker(selected: String, color: ColorToken, onSelect: (String) -> Unit, modifier: Modifier = Modifier, tokens: List = CategoryIcons.ALL) { + val colors = KvittTheme.colors + Column(modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(KvittDimens.iconPickerGap)) { + tokens.chunked(KvittDimens.ICON_PICKER_COLUMNS).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(KvittDimens.iconPickerGap)) { + row.forEach { token -> + val isSelected = token == selected + val name = stringResource(CategoryIconRes.name(token)) + Box( + Modifier + .weight(1f) + .heightIn(min = KvittDimens.iconPickerCell) + .background(if (isSelected) colors.category(color) else MaterialTheme.colorScheme.surfaceContainerHigh, KvittTheme.shapes.menu) + .selectable(isSelected, role = Role.RadioButton) { onSelect(token) } + .semantics { contentDescription = name }, + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(if (isSelected) CategoryIconRes.drawable(token) else CategoryIconRes.outline(token)), + null, + Modifier.size(KvittDimens.icon), + tint = if (isSelected) colors.onCat else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + repeat(KvittDimens.ICON_PICKER_COLUMNS - row.size) { Box(Modifier.weight(1f)) } + } + } + } +} + +/** TalkBack name of a colour token (`color_`; `slate` reads "Plommon", `amber` "Sand"). */ +@StringRes +fun colorName(token: ColorToken): Int = when (token) { + ColorToken.GREEN -> R.string.color_green + ColorToken.ORANGE -> R.string.color_orange + ColorToken.BROWN -> R.string.color_brown + ColorToken.YELLOW -> R.string.color_yellow + ColorToken.BLUE -> R.string.color_blue + ColorToken.TEAL -> R.string.color_teal + ColorToken.PURPLE -> R.string.color_purple + ColorToken.RED -> R.string.color_red + ColorToken.PINK -> R.string.color_pink + ColorToken.GREY -> R.string.color_grey + ColorToken.INDIGO -> R.string.color_indigo + ColorToken.LIME -> R.string.color_lime + ColorToken.SLATE -> R.string.color_slate + ColorToken.AMBER -> R.string.color_amber +} + +/** + * The 14 colours as 44 dp circles in a 7-column grid (design-spec §3.4, S-15). Selected: `check` in `on-cat` plus a + * 3 dp `surface` ring and a 2 dp `on-surface` ring outside it (never colour alone). A radio group. + */ +@Composable +fun ColorSwatchPicker(selected: ColorToken, onSelect: (ColorToken) -> Unit, modifier: Modifier = Modifier) { + val colors = KvittTheme.colors + Column(modifier.fillMaxWidth().selectableGroup(), verticalArrangement = Arrangement.spacedBy(KvittDimens.swatchGap)) { + ColorToken.entries.chunked(KvittDimens.ICON_PICKER_COLUMNS).forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + row.forEach { token -> + val isSelected = token == selected + val name = stringResource(colorName(token)) + val ringed = if (isSelected) { + Modifier + .border(KvittDimens.swatchOuterRing, MaterialTheme.colorScheme.onSurface, CircleShape) + .padding(KvittDimens.swatchOuterRing) + .border(KvittDimens.swatchInnerRing, MaterialTheme.colorScheme.surface, CircleShape) + .padding(KvittDimens.swatchInnerRing) + } else { + Modifier.padding(KvittDimens.swatchOuterRing + KvittDimens.swatchInnerRing) + } + Box( + Modifier + .size(KvittDimens.swatch + (KvittDimens.swatchOuterRing + KvittDimens.swatchInnerRing) * 2) + .selectable(isSelected, role = Role.RadioButton) { onSelect(token) } + .semantics { contentDescription = name } + .then(ringed) + .background(colors.category(token), CircleShape), + contentAlignment = Alignment.Center, + ) { + if (isSelected) KvittIconImage(KvittIcon.Check, null, size = KvittDimens.swatchCheck, tint = colors.onCat) + } + } + } + } + } +} + +/** A colour dot (12 dp) in an account's chart colour, the leading element of the account chip. */ +@Composable +fun AccountDot(colorIndex: Int, modifier: Modifier = Modifier, color: Color = KvittTheme.colors.account(colorIndex)) { + Box(modifier.size(KvittDimens.accountDot).background(color, CircleShape)) +} + diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Common.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Common.kt new file mode 100644 index 0000000..2e9b4c9 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Common.kt @@ -0,0 +1,496 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.BorderStroke +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.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +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.layout.widthIn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +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.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +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.heading +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.KvittElevation +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.ColorToken + +// Scaffolding pieces and common controls of docs/ux/design-spec.md §3.11, plus the card container every card uses. + +/** A content card (`card` fill, radius 28 by default, the soft shadow in light only; design-spec §2.10). */ +@Composable +fun KvittCard( + modifier: Modifier = Modifier, + shape: Shape = KvittTheme.shapes.card, + color: Color = KvittTheme.colors.card, + contentPadding: PaddingValues = PaddingValues(KvittDimens.cardPadding), + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + content: @Composable ColumnScope.() -> Unit, +) { + val shadow = KvittTheme.colors.shadow + val shadowed = if (KvittTheme.isDark) Modifier else Modifier.shadow(KvittElevation.card, shape, ambientColor = shadow, spotColor = shadow) + Column( + modifier.then(shadowed).clip(shape).background(color, shape).padding(contentPadding), + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment, + content = content, + ) +} + +/** The fill of a segmented group's items: `card` (default) or `surface-container-low` (soft). */ +enum class GroupFill { Card, Soft } + +/** One item of a [SegmentedGroup]: its shape and fill. */ +class SegmentedItemScope internal constructor(val index: Int, val count: Int, val shape: Shape, val fill: Color) + +/** + * A vertical group of items 3 dp apart with outer corners 24 and inner corners 8 (design-spec §3.5). Each item draws + * its own background with `scope.shape` and `scope.fill` (so a row can tint itself, as the settlement row does). + */ +@Composable +fun SegmentedGroup(count: Int, modifier: Modifier = Modifier, fill: GroupFill = GroupFill.Card, item: @Composable (SegmentedItemScope) -> Unit) { + val color = if (fill == GroupFill.Card) KvittTheme.colors.card else MaterialTheme.colorScheme.surfaceContainerLow + val shapes = KvittTheme.shapes + val outer = shapes.accountCard + val shadow = KvittTheme.colors.shadow + val shadowed = if (KvittTheme.isDark || fill == GroupFill.Soft) Modifier else Modifier.shadow(KvittElevation.card, outer, ambientColor = shadow, spotColor = shadow) + Column(modifier.then(shadowed), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.groupGap)) { + for (index in 0 until count) item(SegmentedItemScope(index, count, shapes.groupItem(index, count), color)) + } +} + +/** Button kinds of design-spec §3.11. */ +enum class ButtonKind { Filled, Tonal, Tertiary, Outlined, Text, DangerText, ErrorFilled } + +/** Button sizes: small 40 dp, medium 56 dp. */ +enum class ButtonSize { Small, Medium } + +/** A Kvitt button: radius full, label-large weight 600, optional leading icon (design-spec §3.11). */ +@Composable +fun KvittButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + kind: ButtonKind = ButtonKind.Filled, + size: ButtonSize = ButtonSize.Small, + icon: KvittIcon? = null, + iconFilled: Boolean = false, + enabled: Boolean = true, + contentColor: Color? = null, +) { + val scheme = MaterialTheme.colorScheme + val (container, content) = when (kind) { + ButtonKind.Filled -> scheme.primary to scheme.onPrimary + ButtonKind.Tonal -> scheme.secondaryContainer to scheme.onSecondaryContainer + ButtonKind.Tertiary -> scheme.tertiary to scheme.onTertiary + ButtonKind.ErrorFilled -> scheme.error to scheme.onError + ButtonKind.Outlined -> Color.Transparent to (contentColor ?: scheme.onSurfaceVariant) + ButtonKind.Text -> Color.Transparent to (contentColor ?: scheme.primary) + ButtonKind.DangerText -> Color.Transparent to scheme.error + } + val height = if (size == ButtonSize.Small) KvittDimens.buttonSmall else KvittDimens.buttonMedium + val horizontal = if (size == ButtonSize.Small) KvittDimens.buttonSmallHorizontal else KvittDimens.buttonMediumHorizontal + val textStyle = if (size == ButtonSize.Small) MaterialTheme.typography.labelLarge else KvittTheme.typography.buttonMedium + val iconSize = if (size == ButtonSize.Small) KvittDimens.iconButton else KvittDimens.buttonMediumIcon + val colors = ButtonDefaults.buttonColors( + containerColor = container, + contentColor = content, + disabledContainerColor = if (container == Color.Transparent) Color.Transparent else scheme.onSurface.copy(alpha = KvittDimens.DISABLED_CONTAINER_ALPHA), + disabledContentColor = scheme.onSurface.copy(alpha = KvittDimens.DISABLED_CONTENT_ALPHA), + ) + val padding = PaddingValues(horizontal = if (kind == ButtonKind.Text || kind == ButtonKind.DangerText) KvittTheme.spacing.s3 else horizontal) + val label: @Composable RowScope.() -> Unit = { + if (icon != null) { + KvittIconImage(icon, contentDescription = null, filled = iconFilled, size = iconSize) + Spacer(Modifier.width(KvittTheme.spacing.s2)) + } + Text(text, style = textStyle, maxLines = 2, textAlign = TextAlign.Center) + } + val sized = modifier.heightIn(min = height) + if (kind == ButtonKind.Outlined) { + OutlinedButton( + onClick, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, + border = BorderStroke(KvittDimens.divider, scheme.outlineVariant), content = label, + ) + } else if (kind == ButtonKind.Text || kind == ButtonKind.DangerText) { + TextButton(onClick, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, content = label) + } else { + Button(onClick, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, content = label) + } +} + +/** Badge kinds: neutral, lantern and pending (design-spec §3.11). */ +enum class BadgeKind { Neutral, Lantern, Pending } + +/** A small pill with optional icon 14, label-small weight 600 (design-spec §3.11). */ +@Composable +fun KvittBadge(text: String, modifier: Modifier = Modifier, kind: BadgeKind = BadgeKind.Neutral, icon: KvittIcon? = null) { + val (container, content) = when (kind) { + BadgeKind.Neutral -> MaterialTheme.colorScheme.surfaceContainerHighest to MaterialTheme.colorScheme.onSurfaceVariant + BadgeKind.Lantern -> MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer + BadgeKind.Pending -> KvittTheme.colors.pendingBadge to KvittTheme.colors.pending + } + Row( + modifier.background(container, CircleShape).padding(horizontal = KvittDimens.badgeHorizontal, vertical = KvittDimens.badgeVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), + ) { + if (icon != null) KvittIconImage(icon, null, size = KvittDimens.iconBadge, tint = content) + Text(text, color = content, style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.SemiBold), maxLines = 1) + } +} + +/** "Arkiverat" badge with `inventory_2` (archived accounts). */ +@Composable +fun ArchivedBadge(modifier: Modifier = Modifier) { + KvittBadge(stringResource(R.string.common_archived), modifier, icon = KvittIcon.Inventory2) +} + +/** Section title (title-large, heading) with an optional trailing text button (design-spec §3.11). */ +@Composable +fun SectionHeader( + title: String, + modifier: Modifier = Modifier, + action: String? = null, + actionIcon: KvittIcon? = null, + actionEnabled: Boolean = true, + actionSupporting: String? = null, + onAction: () -> Unit = {}, +) { + Row( + modifier.fillMaxWidth().heightIn(min = KvittTheme.spacing.touchTarget).padding(start = KvittTheme.spacing.s2, top = KvittTheme.spacing.s2, end = KvittDimens.sectionHeaderStart), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(title, Modifier.weight(1f).semantics { heading() }, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) + if (action != null) { + Column(horizontalAlignment = Alignment.End) { + KvittButton(action, onAction, kind = ButtonKind.Text, icon = actionIcon, enabled = actionEnabled) + if (actionSupporting != null) NeedsConnectionHint(text = actionSupporting) + } + } + } +} + +/** "Arkiverade (1)" with `inventory_2` and an expand chevron; button with state (design-spec §3.11). */ +@Composable +fun CollapsibleRow(title: String, expanded: Boolean, expandedState: String, collapsedState: String, onToggle: () -> Unit, modifier: Modifier = Modifier) { + Row( + modifier + .fillMaxWidth() + .heightIn(min = KvittTheme.spacing.touchTarget) + .clip(KvittTheme.shapes.strip) + .clickable(role = Role.Button, onClick = onToggle) + .semantics(mergeDescendants = true) { stateDescription = if (expanded) expandedState else collapsedState } + .padding(horizontal = KvittTheme.spacing.s2, vertical = KvittDimens.collapsibleVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + KvittIconImage(KvittIcon.Inventory2, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(title, Modifier.weight(1f), style = MaterialTheme.typography.titleSmall) + KvittIconImage(if (expanded) KvittIcon.ExpandLess else KvittIcon.ExpandMore, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** A detail fact as a group item: outline icon, label over value; one merged node "Datum: fredag 16 oktober 2026". */ +@Composable +fun FactRow( + item: SegmentedItemScope, + icon: KvittIcon, + label: String, + value: String, + modifier: Modifier = Modifier, + spoken: String = "$label: $value", + extra: (@Composable ColumnScope.() -> Unit)? = null, + valueLeading: (@Composable () -> Unit)? = null, +) { + Row( + modifier + .fillMaxWidth() + .background(item.fill, item.shape) + .heightIn(min = KvittTheme.spacing.rowMin) + .clearAndSetSemantics { contentDescription = spoken } + .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + KvittIconImage(icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + valueLeading?.invoke() + Text(value, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + } + extra?.invoke(this) + } + } +} + +/** + * A settings row as a group item: a 40 dp tile (category tile shape, `cat-` container fill, icon 22 in the + * colour), title over subtitle, `chevron_right`; merged node, role button (design-spec §3.11). + */ +@Composable +fun SettingsRow( + item: SegmentedItemScope, + icon: KvittIcon, + color: ColorToken, + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + enabled: Boolean = true, +) { + val colors = KvittTheme.colors + Row( + modifier + .fillMaxWidth() + .clip(item.shape) + .background(item.fill) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) {} + .heightIn(min = KvittTheme.spacing.rowMin) + .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical) + .alpha(if (enabled) 1f else KvittDimens.DISABLED_CONTENT_ALPHA * 2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + Box(Modifier.size(KvittDimens.tileLedger).background(colors.categoryContainer(color), KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) { + KvittIconImage(icon, null, filled = true, size = KvittDimens.settingsTileIcon, tint = colors.category(color)) + } + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium) + if (subtitle != null) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (enabled) KvittIconImage(KvittIcon.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * Empty state: flat section, centred, decorative illustration (three rotated category tiles), title, body and an + * optional medium button (design-spec §3.11). + */ +@Composable +fun EmptyState( + title: String, + body: String, + modifier: Modifier = Modifier, + illustration: List = emptyList(), + illustrationContainer: Boolean = false, + action: String? = null, + actionIcon: KvittIcon? = null, + actionKind: ButtonKind = ButtonKind.Filled, + onAction: () -> Unit = {}, +) { + Column( + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card) + .padding(horizontal = KvittDimens.emptyHorizontal, vertical = KvittDimens.emptyVertical), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KvittDimens.emptyGap), + ) { + if (illustration.isNotEmpty()) TilePile(illustration, illustrationContainer) + Text(title, style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Text(body, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + if (action != null) KvittButton(action, onAction, kind = actionKind, size = ButtonSize.Medium, icon = actionIcon) + } +} + +@Composable +private fun TilePile(categories: List, container: Boolean) { + val style = if (container) TileStyle.Container else TileStyle.Solid + val angles = listOf(-12f, 6f, -4f) + Box(Modifier.size(KvittDimens.illustrationWidth, KvittDimens.illustrationHeight).clearAndSetSemantics { }) { + categories.take(3).forEachIndexed { index, category -> + val x = KvittDimens.illustrationWidth * (0.08f + 0.29f * index) + val y = if (index == 1) 0.dp else KvittDimens.illustrationHeight * 0.3f + CategoryTile( + category, + KvittDimens.tileHeader, + style = style, + modifier = Modifier + .padding(start = x, top = y) + .rotate(angles[index]), + ) + } + } +} + +/** + * A dialog: `surface-container-high`, radius 28, padding 24, gap 14; centred icon tile (destructive: error + * container), centred title, muted body, right-aligned dismiss and confirm (design-spec §3.11). + */ +@Composable +fun ConfirmDialog( + icon: KvittIcon, + title: String, + body: String, + confirm: String, + dismiss: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + destructive: Boolean = false, + tertiaryTile: Boolean = false, + confirmIcon: KvittIcon? = null, + extra: (@Composable ColumnScope.() -> Unit)? = null, +) { + Dialog(onDismissRequest = onDismiss) { ConfirmDialogContent(icon, title, body, confirm, dismiss, onConfirm, onDismiss, destructive, tertiaryTile, confirmIcon, extra) } +} + +/** The content of [ConfirmDialog], also used to screenshot the dialog without a window. */ +@Composable +fun ConfirmDialogContent( + icon: KvittIcon, + title: String, + body: String, + confirm: String, + dismiss: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + destructive: Boolean = false, + tertiaryTile: Boolean = false, + confirmIcon: KvittIcon? = null, + extra: (@Composable ColumnScope.() -> Unit)? = null, +) { + val scheme = MaterialTheme.colorScheme + val (tile, onTile) = when { + destructive -> scheme.errorContainer to scheme.onErrorContainer + tertiaryTile -> scheme.tertiaryContainer to scheme.onTertiaryContainer + else -> scheme.secondaryContainer to scheme.onSecondaryContainer + } + Surface(shape = KvittTheme.shapes.card, color = scheme.surfaceContainerHigh, shadowElevation = KvittElevation.overlay) { + Column(Modifier.padding(KvittDimens.dialogPadding), verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap)) { + Box(Modifier.align(Alignment.CenterHorizontally).size(KvittDimens.dialogIconTile).background(tile, KvittTheme.shapes.avatarLarge), contentAlignment = Alignment.Center) { + KvittIconImage(icon, null, filled = true, tint = onTile) + } + Text(title, Modifier.fillMaxWidth(), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center) + Text(body, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant) + extra?.invoke(this) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) { + KvittButton(dismiss, onDismiss, kind = ButtonKind.Text) + KvittButton(confirm, onConfirm, kind = if (destructive) ButtonKind.ErrorFilled else ButtonKind.Filled, icon = confirmIcon) + } + } + } +} + +/** One item of a [KvittMenu]: icon, label, and the reason it is disabled as a second line. */ +data class KvittMenuItem(val icon: KvittIcon, val label: String, val enabled: Boolean = true, val disabledReason: String? = null, val onClick: () -> Unit = {}) + +/** Menu surface (design-spec §3.11): radius 16, padding 6, width 244–300, items 48 high, radius 12. */ +@Composable +fun KvittMenuContent(items: List, modifier: Modifier = Modifier) { + Surface(modifier.widthIn(min = KvittDimens.menuMinWidth, max = KvittDimens.menuMaxWidth), shape = KvittTheme.shapes.menu, color = MaterialTheme.colorScheme.surfaceContainer, shadowElevation = KvittElevation.overlay) { + Column(Modifier.padding(KvittDimens.menuPadding)) { + items.forEach { item -> + val spoken = if (item.enabled || item.disabledReason == null) item.label else "${item.label}, ${item.disabledReason}" + Row( + Modifier + .fillMaxWidth() + .heightIn(min = KvittTheme.spacing.touchTarget) + .clip(KvittTheme.shapes.strip) + .clickable(enabled = item.enabled, onClick = item.onClick) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button } + .padding(KvittTheme.spacing.s3) + .alpha(if (item.enabled) 1f else KvittDimens.DISABLED_REASON_ALPHA), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + KvittIconImage(item.icon, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Column { + Text(item.label, style = MaterialTheme.typography.bodyLarge) + if (!item.enabled && item.disabledReason != null) { + Text(item.disabledReason, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } + } +} + +/** Snackbar look of design-spec §3.11: `inverse-surface`, action `inverse-primary`, radius 16. */ +@Composable +fun KvittSnackbar(message: String, modifier: Modifier = Modifier, action: String? = null, onAction: () -> Unit = {}) { + val scheme = MaterialTheme.colorScheme + Surface(modifier.fillMaxWidth(), shape = KvittTheme.shapes.menu, color = scheme.inverseSurface, contentColor = scheme.inverseOnSurface, shadowElevation = KvittElevation.overlay) { + Row(Modifier.padding(start = KvittTheme.spacing.s4, end = KvittTheme.spacing.s2).heightIn(min = KvittTheme.spacing.touchTarget), verticalAlignment = Alignment.CenterVertically) { + Text(message, Modifier.weight(1f).padding(vertical = KvittTheme.spacing.s3), style = MaterialTheme.typography.bodyMedium) + if (action != null) KvittButton(action, onAction, kind = ButtonKind.Text, contentColor = scheme.inversePrimary) + } + } +} + +/** How long a snackbar stays: 4 s without an action, 10 s with one (design-spec §3.11). */ +fun snackbarMillis(hasAction: Boolean): Long = if (hasAction) 10_000L else 4_000L + +/** Loading placeholder blocks (`surface-container-highest`, radius 12); first sync and online fetches only. */ +@Composable +fun Skeleton(width: Dp, height: Dp, modifier: Modifier = Modifier, pill: Boolean = false) { + Box(modifier.size(width, height).background(MaterialTheme.colorScheme.surfaceContainerHighest, if (pill) CircleShape else KvittTheme.shapes.skeleton).clearAndSetSemantics { }) +} + +/** A 1 dp `outline-variant` divider. */ +@Composable +fun KvittDivider(modifier: Modifier = Modifier) { + HorizontalDivider(modifier, thickness = KvittDimens.divider, color = MaterialTheme.colorScheme.outlineVariant) +} + +/** [color] with its saturation scaled to [saturation] (0 = grey), for archived tiles (design-spec §3.4). */ +fun Color.desaturate(saturation: Float): Color { + val grey = luminance().let { Color(it, it, it, alpha) } + return Color( + red = grey.red + (red - grey.red) * saturation, + green = grey.green + (green - grey.green) * saturation, + blue = grey.blue + (blue - grey.blue) * saturation, + alpha = alpha, + ) +} + +/** [top] at [fraction] opacity over [bottom], as the derived tints of design-spec §2.2 are made. */ +fun mix(top: Color, fraction: Float, bottom: Color): Color = top.copy(alpha = fraction).compositeOver(bottom) diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt new file mode 100644 index 0000000..7dbcc4c --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt @@ -0,0 +1,540 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +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.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +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.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.CheckBadgeIcon +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.design.theme.KvittFontCaps +import se.hagstedt.kvitt.design.theme.capped +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.Share +import se.hagstedt.kvitt.model.User + +// The expense editor fields of docs/ux/design-spec.md §3.7 (D-019; S-7 and S-19). The split editors are in Split.kt. + +/** + * The amount (design-spec §3.7): label "Belopp", the typed text in `amount-hero` with a 3 dp `primary` caret when + * focused, then the unit in headline-small muted; "0,00" as a muted hint when empty. [text] is what the user typed, + * already grouped ([se.hagstedt.kvitt.design.format.AmountInput.display]). Tap focuses it. + */ +@Composable +fun AmountDisplay(text: String, focused: Boolean, error: String?, onClick: () -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val language = kvittLanguage() + val label = stringResource(R.string.editor_amount) + val hint = MoneyFormatter.number(0, language) + val unit = MoneyFormatter.unit(language) + val spokenValue = if (text.isEmpty()) hint else stringResource(R.string.common_amount_spoken, text) + val caretHeight = with(LocalDensity.current) { KvittTheme.typography.amountHero.fontSize.toDp() } + val spoken = listOfNotNull(label, spokenValue, error).joinToString(stringResource(R.string.common_separator_comma)) + Column( + modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button } + .padding(top = KvittDimens.amountTop, bottom = KvittDimens.amountBottom), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(label, style = MaterialTheme.typography.labelMedium, color = if (error != null) scheme.error else scheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.Center, itemVerticalAlignment = Alignment.Bottom) { + if (language == KvittLanguage.ENGLISH) { + Text("$unit ", Modifier.alignBy(FirstBaseline), style = MaterialTheme.typography.headlineSmall, color = scheme.onSurfaceVariant) + } + Row(Modifier.alignBy(FirstBaseline), verticalAlignment = Alignment.CenterVertically) { + Text( + text.ifEmpty { hint }, + style = KvittTheme.typography.amountHero, + color = if (text.isEmpty()) scheme.onSurfaceVariant else scheme.onSurface, + maxLines = 1, + softWrap = false, + ) + if (focused) Box(Modifier.width(KvittDimens.caretWidth).height(caretHeight).background(scheme.primary)) + } + if (language == KvittLanguage.SWEDISH) { + Text(" $unit", Modifier.alignBy(FirstBaseline), style = MaterialTheme.typography.headlineSmall, color = scheme.onSurfaceVariant) + } + } + if (error != null) Text(error, style = MaterialTheme.typography.bodySmall, color = scheme.error, textAlign = TextAlign.Center) + } +} + +/** + * The in-app keypad sheet (design-spec §3.7): `surface-container`, top corners 28, a 3-column grid of `1`–`9`, the + * decimal key ("," or "."), `0` and delete (`primary-container`). Keys are 52 dp (46 in [compact]) with labels capped + * at 1.5×; each key gives a keyboard-tap haptic. Long-press or the TalkBack action "Rensa" on delete clears. + * The caller applies the input rules ([se.hagstedt.kvitt.design.format.AmountInput.press]). + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun AmountKeypad(onKey: (AmountKey) -> Unit, modifier: Modifier = Modifier, compact: Boolean = false) { + val scheme = MaterialTheme.colorScheme + val haptics = LocalHapticFeedback.current + val language = kvittLanguage() + val keyHeight = if (compact) KvittDimens.keypadKeyCompact else KvittDimens.keypadKey + val keyStyle = KvittTheme.typography.keypadKey.capped(KvittFontCaps.KEYPAD_KEY) + val deleteLabel = stringResource(R.string.editor_keypad_delete) + val clearLabel = stringResource(R.string.editor_keypad_clear) + val decimalLabel = stringResource(R.string.editor_keypad_decimal) + val groupLabel = stringResource(R.string.editor_keypad) + fun press(key: AmountKey) { + haptics.performHapticFeedback(HapticFeedbackType.KeyboardTap) + onKey(key) + } + Column( + modifier + .fillMaxWidth() + .background(scheme.surfaceContainer, KvittTheme.shapes.sheetTop) + .semantics { contentDescription = groupLabel } + .padding(start = KvittDimens.keypadPadding, end = KvittDimens.keypadPadding, top = KvittDimens.keypadPadding, bottom = KvittDimens.keypadBottom), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + val rows = listOf(listOf(1, 2, 3), listOf(4, 5, 6), listOf(7, 8, 9)) + rows.forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + row.forEach { digit -> + KeypadKey(Modifier.weight(1f), keyHeight, scheme.surfaceContainerHighest, onClick = { press(AmountKey.Digit(digit)) }) { + Text(digit.toString(), style = keyStyle, color = scheme.onSurface) + } + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KeypadKey(Modifier.weight(1f).semantics { contentDescription = decimalLabel }, keyHeight, scheme.surfaceContainerHighest, onClick = { press(AmountKey.Decimal) }) { + Text(MoneyFormatter.decimalSeparator(language).toString(), style = keyStyle, color = scheme.onSurface) + } + KeypadKey(Modifier.weight(1f), keyHeight, scheme.surfaceContainerHighest, onClick = { press(AmountKey.Digit(0)) }) { + Text("0", style = keyStyle, color = scheme.onSurface) + } + KeypadKey( + Modifier + .weight(1f) + .semantics { + contentDescription = deleteLabel + customActions = listOf(CustomAccessibilityAction(clearLabel) { press(AmountKey.Clear); true }) + }, + keyHeight, + scheme.primaryContainer, + onClick = { press(AmountKey.Delete) }, + onLongClick = { press(AmountKey.Clear) }, + ) { + KvittIconImage(KvittIcon.Backspace, null, size = KvittDimens.keypadDeleteIcon, tint = scheme.onPrimaryContainer) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun KeypadKey(modifier: Modifier, height: Dp, fill: Color, onClick: () -> Unit, onLongClick: (() -> Unit)? = null, content: @Composable () -> Unit) { + Box( + modifier + .heightIn(min = height) + .clip(CircleShape) + .background(fill) + .combinedClickable(role = Role.Button, onClick = onClick, onLongClick = onLongClick), + contentAlignment = Alignment.Center, + ) { content() } +} + +/** The description field: "Beskrivning", single line, sentence capitalisation, IME Done (design-spec §3.7). */ +@Composable +fun DescriptionField(value: String, onValueChange: (String) -> Unit, error: String?, onDone: () -> Unit, modifier: Modifier = Modifier) { + KvittTextField(value, onValueChange, stringResource(R.string.editor_description), modifier, error = error, onIme = onDone) +} + +/** + * The five tiles of the quick row (design-spec §3.7, UX-3): [frequent] (the account's most used active categories, + * `ExpenseEditorOptions.frequentCategoryIds`, D-059) filled up from [active] in `sort_order`; when [selected] is not + * among them it takes the fifth place. Without usage data pass an empty [frequent]: the row is then the selected + * category followed by the first active categories (the fallback of §3.7). + */ +fun quickRowCategories(active: List, frequent: List, selected: CategoryId?): List { + val byId = active.associateBy { it.id } + val sorted = active.filter { !it.archived }.sortedBy { it.sortOrder } + val chosen = if (frequent.isEmpty()) { + listOfNotNull(selected?.let { byId[it] }) + sorted + } else { + frequent.mapNotNull { byId[it] } + sorted + }.distinctBy { it.id }.take(KvittDimens.QUICK_ROW_TILES) + val pick = selected?.let { byId[it] } ?: return chosen + return if (chosen.any { it.id == pick.id }) chosen else chosen.take(KvittDimens.QUICK_ROW_TILES - 1) + pick +} + +/** + * The category quick row (design-spec §3.7): a 6-column grid of five category items and "Alla" (3 columns, two rows at + * ≥ 1.5× font scale). Item: tile 52 (container; solid with a check badge when selected) over a two-line label. A radio + * group "Kategori"; items read "Nöje, vald". + */ +@Composable +fun CategoryQuickRow(categories: List, selected: CategoryId?, onPick: (Category) -> Unit, onAll: () -> Unit, modifier: Modifier = Modifier) { + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + val columns = if (large) KvittDimens.QUICK_ROW_COLUMNS_LARGE else KvittDimens.QUICK_ROW_COLUMNS + val group = stringResource(R.string.editor_category) + val slots: List = categories + null + Column( + modifier.fillMaxWidth().selectableGroup().semantics { contentDescription = group }, + verticalArrangement = Arrangement.spacedBy(if (large) KvittDimens.quickRowLargeRowGap else KvittDimens.quickRowGap), + ) { + slots.chunked(columns).forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittDimens.quickRowGap)) { + row.forEach { category -> + if (category == null) AllItem(onAll, Modifier.weight(1f)) + else CategoryItem(category, category.id == selected, { onPick(category) }, Modifier.weight(1f)) + } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +/** One category item of the quick row or the grid sheet. */ +@Composable +fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val name = categoryName(category) + Column( + modifier + .clip(KvittTheme.shapes.menu) + .selectable(selected, role = Role.RadioButton, onClick = onClick) + .semantics(mergeDescendants = true) {} + .padding(vertical = KvittTheme.spacing.s1), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + Box { + CategoryTile(category, KvittDimens.tileEditor, style = if (selected) TileStyle.Solid else TileStyle.Container) + if (selected) { + Box( + Modifier + .align(Alignment.TopEnd) + .offset(x = KvittDimens.checkBadgeOffsetX, y = -KvittDimens.checkBadgeOffsetY) + .size(KvittDimens.checkBadge + KvittDimens.checkBadgeRing * 2) + .background(scheme.surface, CircleShape) + .padding(KvittDimens.checkBadgeRing) + .background(scheme.onSurface, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(CheckBadgeIcon), + null, + Modifier.size(KvittDimens.iconBadge), + tint = scheme.surface, + ) + } + } + } + Text( + name, + style = KvittTheme.typography.tileLabel.let { if (selected) it.copy(fontWeight = FontWeight.Bold) else it }, + color = if (selected) scheme.onSurface else scheme.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 2, + ) + } +} + +@Composable +private fun AllItem(onAll: () -> Unit, modifier: Modifier) { + val scheme = MaterialTheme.colorScheme + val spoken = stringResource(R.string.editor_category_all_spoken) + Column( + modifier + .clip(KvittTheme.shapes.menu) + .clickable(role = Role.Button, onClick = onAll) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button } + .padding(vertical = KvittTheme.spacing.s1), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + Box(Modifier.size(KvittDimens.tileEditor).background(scheme.surfaceContainerHighest, KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) { + KvittIconImage(KvittIcon.Apps, null, size = KvittDimens.allIcon, tint = scheme.onSurfaceVariant) + } + Text(stringResource(R.string.editor_category_all), style = KvittTheme.typography.tileLabel, color = scheme.onSurfaceVariant, textAlign = TextAlign.Center) + } +} + +/** + * The content of the "Välj kategori" sheet (design-spec §3.7): all active categories in `sort_order`, 4 columns (3 at + * ≥ 1.5×), items as in the quick row. The feature hosts it in a `ModalBottomSheet` and closes it on pick. + */ +@Composable +fun CategoryGrid(categories: List, selected: CategoryId?, onPick: (Category) -> Unit, modifier: Modifier = Modifier) { + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + val columns = if (large) KvittDimens.GRID_SHEET_COLUMNS_LARGE else KvittDimens.GRID_SHEET_COLUMNS + Column(modifier.fillMaxWidth().selectableGroup(), verticalArrangement = Arrangement.spacedBy(KvittDimens.quickRowLargeRowGap)) { + Text(stringResource(R.string.editor_pick_category), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(horizontal = KvittTheme.spacing.s2)) + categories.filter { !it.archived }.sortedBy { it.sortOrder }.chunked(columns).forEach { row -> + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittDimens.quickRowGap)) { + row.forEach { CategoryItem(it, it.id == selected, { onPick(it) }, Modifier.weight(1f)) } + repeat(columns - row.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +/** + * A chip of the editor's chips row (design-spec §3.7): min height 36 (48 target), radius full, + * `surface-container-high` (`secondary-container` when [selected]), an optional muted [keyPrefix] ("Betalt av "), + * an 18 dp leading element and trailing icon. [spoken] is the chip's TalkBack sentence. + */ +@Composable +fun EditorChip( + label: String, + spoken: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + keyPrefix: String? = null, + selected: Boolean = false, + trailing: KvittIcon? = KvittIcon.ExpandMore, + leading: (@Composable () -> Unit)? = null, +) { + val scheme = MaterialTheme.colorScheme + val fill = if (selected) scheme.secondaryContainer else scheme.surfaceContainerHigh + val content = if (selected) scheme.onSecondaryContainer else scheme.onSurface + val text = buildAnnotatedString { + if (keyPrefix != null) withStyle(SpanStyle(color = if (selected) content else scheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold)) { append(keyPrefix) } + append(label) + } + Box(modifier.heightIn(min = KvittTheme.spacing.touchTarget), contentAlignment = Alignment.Center) { + Row( + Modifier + .heightIn(min = KvittDimens.chipMinHeight) + .clip(CircleShape) + .background(fill) + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button } + .padding(start = KvittDimens.chipStart, end = KvittDimens.chipEnd, top = KvittDimens.chipTop, bottom = KvittDimens.chipTop), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + leading?.invoke() + Text(text, style = KvittTheme.typography.chip, color = content, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (trailing != null) KvittIconImage(trailing, null, size = KvittDimens.iconChip, tint = content) + } + } +} + +/** The chips row: a wrapping row, gap 6 (design-spec §3.7). */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun EditorChipRow(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + FlowRow(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittDimens.chipGap)) { content() } +} + +/** + * The account chip: a 12 dp dot in the account's chart colour, its name, `expand_more`; TalkBack "Konto: Vardag". + */ +@Composable +fun AccountChip(name: String, colorIndex: Int, onClick: () -> Unit, modifier: Modifier = Modifier) { + EditorChip(name, stringResource(R.string.editor_account_spoken, name), onClick, modifier, leading = { AccountDot(colorIndex) }) +} + +/** The date chip: `event`, "Idag" / "Igår" / "14 okt."; TalkBack "Datum: idag". */ +@Composable +fun DateChip(isoDate: String, onClick: () -> Unit, modifier: Modifier = Modifier, keyPrefix: String? = null) { + val clock = LocalKvittClock.current + val today = clock.today + val date = DateFormatter.parseDate(isoDate) + val label = when (date.toEpochDays() - today.toEpochDays()) { + 0L -> stringResource(R.string.common_today) + -1L -> stringResource(R.string.common_yesterday) + else -> listDate(isoDate) + } + EditorChip( + label, + stringResource(R.string.common_date_spoken, label.replaceFirstChar { it.lowercaseChar() }), + onClick, + modifier, + keyPrefix = keyPrefix, + leading = { KvittIconImage(KvittIcon.Event, null, size = KvittDimens.iconChip) }, + ) +} + +/** The payer chip: payer avatar 24, "Betalt av Du", `swap_horiz`; one tap toggles (AC-8.3). */ +@Composable +fun PayerChip(payer: User, other: User, onToggle: () -> Unit, modifier: Modifier = Modifier) { + val prefix = stringResource(R.string.editor_paid_by) + " " + EditorChip( + personName(payer), + stringResource(R.string.editor_paid_by_toggle, personNameInSentence(payer), personNameInSentence(other)), + onToggle, + modifier, + keyPrefix = prefix, + trailing = KvittIcon.SwapHoriz, + leading = { PersonAvatar(payer, KvittDimens.avatarChip) }, + ) +} + +/** The split chip: `call_split`, "Delning ", expand_more / expand_less; selected while the section is open. */ +@Composable +fun SplitChip(modeLabel: String, open: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val prefix = stringResource(R.string.editor_split) + EditorChip( + modeLabel, + "$prefix: ${modeLabel.replaceFirstChar { it.lowercaseChar() }}", + onClick, + modifier, + keyPrefix = "$prefix ", + selected = open, + trailing = if (open) KvittIcon.ExpandLess else KvittIcon.ExpandMore, + leading = { KvittIconImage(KvittIcon.CallSplit, null, size = KvittDimens.iconChip) }, + ) +} + +/** One account offered by [AccountPicker]: the summary for its wording and its 0-based colour position. */ +data class AccountChoice(val summary: AccountSummary, val colorIndex: Int) + +/** + * The content of the "Välj konto" sheet (design-spec §3.7): active accounts in created order (archived are not + * offered, AC-6.4), each with tile 40, name, wording and amount (body-small, coloured), check on the selected one. + */ +@Composable +fun AccountPicker(choices: List, partner: User, selected: AccountId?, onPick: (AccountChoice) -> Unit, modifier: Modifier = Modifier) { + Column(modifier.fillMaxWidth().selectableGroup()) { + Text(stringResource(R.string.editor_pick_account), style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(KvittTheme.spacing.s2)) + choices.filter { !it.summary.account.archived }.forEach { choice -> + val account = choice.summary.account + val net = choice.summary.netOre + val isSelected = account.id == selected + Row( + Modifier + .fillMaxWidth() + .heightIn(min = KvittTheme.spacing.rowMin) + .clip(KvittTheme.shapes.strip) + .selectable(isSelected, role = Role.RadioButton) { onPick(choice) } + .semantics(mergeDescendants = true) {} + .padding(horizontal = KvittTheme.spacing.s2, vertical = KvittTheme.spacing.s2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + AccountTile(account.name, choice.colorIndex, size = KvittDimens.tileLedger) + Column(Modifier.weight(1f)) { + Text(account.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + val wording = if (net == 0L) balanceWording(net, partner, BalanceScope.Account) else balanceWording(net, partner, BalanceScope.Account) + " " + money(net) + Text(wording, style = MaterialTheme.typography.bodySmall, color = toneOf(net).color()) + } + if (isSelected) KvittIconImage(KvittIcon.Check, null, tint = MaterialTheme.colorScheme.primary) + } + } + } +} + +/** + * The notes field (design-spec §3.7): collapsed as the text button "Lägg till anteckning"; expanded a multi-line field + * "Anteckningar", 3–8 lines, counter "120 / 1000". + */ +@Composable +fun NotesField(value: String, onValueChange: (String) -> Unit, expanded: Boolean, onExpand: () -> Unit, modifier: Modifier = Modifier, maxLength: Int = NOTES_MAX) { + if (!expanded) { + KvittButton(stringResource(R.string.editor_add_note), onExpand, modifier, kind = ButtonKind.Text, icon = KvittIcon.Notes) + return + } + KvittTextField( + value, + onValueChange, + stringResource(R.string.editor_notes), + modifier, + supporting = "${value.codePointCount(0, value.length)} / $maxLength", + singleLine = false, + minLines = KvittDimens.NOTES_MIN_LINES, + maxLines = KvittDimens.NOTES_MAX_LINES, + imeAction = ImeAction.Default, + ) +} + +private const val NOTES_MAX = 1000 + +/** + * The compact row pinned under the app bar when the amount has scrolled away (design-spec §3.7): tile 36, + * description over the category name, `MoneyText(Medium)`, a 1 dp bottom border. Tap scrolls up and focuses the amount. + */ +@Composable +fun CompactEditorSummary(category: Category, description: String, amountOre: Long, onClick: () -> Unit, modifier: Modifier = Modifier) { + val name = categoryName(category) + val spoken = listOf(description, name, spokenMoney(amountOre)).joinToString(stringResource(R.string.common_separator_comma)) + Column(modifier.fillMaxWidth().clickable(onClick = onClick).clearAndSetSemantics { contentDescription = spoken; role = Role.Button }) { + Row( + Modifier.fillMaxWidth().padding(start = KvittDimens.compactSide, end = KvittDimens.compactSide, top = KvittDimens.compactTop, bottom = KvittDimens.compactBottom), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap), + ) { + CategoryTile(category, KvittDimens.tileSmall) + Column(Modifier.weight(1f)) { + Text(description, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(name, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + MoneyText(amountOre, AmountStyle.Medium, AmountTone.Neutral) + } + KvittDivider() + } +} + +/** Formats a share list for TalkBack and the preview: "Du 206,00 kr · Anna 206,00 kr". */ +@Composable +fun shareText(shares: List, me: User, partner: User, spoken: Boolean): String { + val byUser = shares.associateBy { it.userId } + val people = listOf(me, partner) + val separator = if (spoken) stringResource(R.string.common_separator_comma) else stringResource(R.string.common_separator_dot) + return people.mapNotNull { user -> + val share = byUser[user.id] ?: return@mapNotNull null + stringResource(R.string.common_user_split, personName(user), if (spoken) spokenMoney(share.shareOre) else money(share.shareOre)) + }.joinToString(separator) +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Environment.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Environment.kt new file mode 100644 index 0000000..f004afd --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Environment.kt @@ -0,0 +1,164 @@ +package se.hagstedt.kvitt.design.component + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.format.KvittLanguage +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId +import kotlin.time.Clock +import kotlin.time.Instant + +/** The formatting language of the current configuration (Swedish, or English forms for every other locale, FR-19). */ +@Composable +@ReadOnlyComposable +fun kvittLanguage(): KvittLanguage = KvittLanguage.of(LocalConfiguration.current.locales[0].language) + +/** The clock components read for relative times and "today" (design-spec §3.6, §9.8). Tests provide a fixed one. */ +@Immutable +class KvittClock(val now: () -> Instant, val zone: TimeZone) { + val today: LocalDate get() = now().toLocalDateTime(zone).date + + companion object { + val System = KvittClock({ Clock.System.now() }, TimeZone.currentSystemDefault()) + + fun fixed(now: Instant, zone: TimeZone): KvittClock = KvittClock({ now }, zone) + } +} + +val LocalKvittClock = staticCompositionLocalOf { KvittClock.System } + +/** + * The household as the components need it: who the viewer is and which avatar slot each person has (AC-4.5, D-052, + * design-spec §2.5). Screens provide it with [ProvideHousehold]. + */ +@Immutable +data class KvittHousehold(val viewer: UserId?, val userIds: List) { + /** Slot 1 or 2 of [user], see [AvatarSlots.slotOf]. */ + fun slotOf(user: UserId): Int = AvatarSlots.slotOf(user, userIds) + + fun isViewer(user: UserId): Boolean = user == viewer +} + +/** The stable slot function of AC-4.5 (design-spec §2.5). */ +object AvatarSlots { + /** + * The household user whose id sorts first (plain comparison of the lowercase id) has slot 1, the other slot 2; a + * household of one has only slot 1. A user outside [household] is ranked among its ids as if it belonged. + */ + fun slotOf(user: UserId, household: List): Int { + val ids = (household + user).map { it.value.lowercase() }.distinct().sorted() + return if (ids.indexOf(user.value.lowercase()) == 0) 1 else 2 + } +} + +val LocalKvittHousehold = staticCompositionLocalOf { KvittHousehold(viewer = null, userIds = emptyList()) } + +/** Provides the household (viewer and both users) to every component below. */ +@Composable +fun ProvideHousehold(me: User, partner: User?, content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalKvittHousehold provides KvittHousehold(me.id, listOfNotNull(me.id, partner?.id)), + content = content, + ) +} + +/** `1 234,50 kr` / `SEK 1,234.50` in the current language (SPEC §9.8). */ +@Composable +@ReadOnlyComposable +fun money(ore: Long): String = MoneyFormatter.format(ore, kvittLanguage()) + +/** TalkBack form of an amount: "1 234,50 kronor" / "1,234.50 Swedish kronor" (`common_amount_spoken`). */ +@Composable +@ReadOnlyComposable +fun spokenMoney(ore: Long): String = stringResource(R.string.common_amount_spoken, MoneyFormatter.number(ore, kvittLanguage())) + +/** List date of an ISO date relative to the clock's today: "16 okt." (SPEC §9.8). */ +@Composable +@ReadOnlyComposable +fun listDate(isoDate: String): String = DateFormatter.listDate(DateFormatter.parseDate(isoDate), LocalKvittClock.current.today, kvittLanguage()) + +/** Detail date: "fredag 16 oktober 2026". */ +@Composable +@ReadOnlyComposable +fun detailDate(isoDate: String): String = DateFormatter.detailDate(isoDate, kvittLanguage()) + +/** The display name of a person as the viewer reads it: "Du" for the viewer, else the display name. */ +@Composable +@ReadOnlyComposable +fun personName(user: User): String = + if (LocalKvittHousehold.current.isViewer(user.id)) stringResource(R.string.common_you) else user.displayName + +/** The viewer inside a sentence: "du", else the display name. */ +@Composable +@ReadOnlyComposable +fun personNameInSentence(user: User): String = + if (LocalKvittHousehold.current.isViewer(user.id)) stringResource(R.string.common_you_lower) else user.displayName + +/** The name of a category: the custom name, else the localised default name (AC-7.2, §14.2.4). */ +@Composable +@ReadOnlyComposable +fun categoryName(category: Category): String = + category.customName ?: category.defaultKey?.let { stringResource(defaultCategoryName(it)) } ?: "" + +/** The localised name of a default category (`category_default_`). */ +@StringRes +fun defaultCategoryName(key: DefaultCategoryKey): Int = when (key) { + DefaultCategoryKey.GROCERIES -> R.string.category_default_groceries + DefaultCategoryKey.RESTAURANTS -> R.string.category_default_restaurants + DefaultCategoryKey.HOUSING -> R.string.category_default_housing + DefaultCategoryKey.HOME -> R.string.category_default_home + DefaultCategoryKey.UTILITIES -> R.string.category_default_utilities + DefaultCategoryKey.INSURANCE -> R.string.category_default_insurance + DefaultCategoryKey.TRANSPORT -> R.string.category_default_transport + DefaultCategoryKey.TRAVEL -> R.string.category_default_travel + DefaultCategoryKey.ENTERTAINMENT -> R.string.category_default_entertainment + DefaultCategoryKey.HEALTH -> R.string.category_default_health + DefaultCategoryKey.CHILDREN -> R.string.category_default_children + DefaultCategoryKey.SHOPPING -> R.string.category_default_shopping + DefaultCategoryKey.FEES -> R.string.category_default_fees + DefaultCategoryKey.OTHER -> R.string.category_default_other +} + +/** + * A Material Symbol of design-spec §2.12, dp-sized (icons never scale with the font). [contentDescription] null marks it + * decorative. + */ +@Composable +fun KvittIconImage( + icon: KvittIcon, + contentDescription: String?, + modifier: Modifier = Modifier, + filled: Boolean = false, + size: Dp = KvittDimens.icon, + tint: Color = LocalContentColor.current, +) { + Icon( + painter = painterResource(if (filled) icon.filled else icon.outline), + contentDescription = contentDescription, + modifier = modifier.size(size), + tint = tint, + ) +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Fields.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Fields.kt new file mode 100644 index 0000000..e818e63 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Fields.kt @@ -0,0 +1,194 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens + +/** + * Kvitt's filled text field (design-spec §3.7 `DescriptionField`, S-6, S-9, S-14, S-15): radius 16 on all corners, + * `surface-container-highest`, no indicator line, a 2 dp `primary` outline when focused and `error` on error, the error + * as supporting text with a filled `error` icon. [readOnly] draws the transparent read-only look (1 dp outline-variant). + */ +@Composable +fun KvittTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + error: String? = null, + supporting: String? = null, + placeholder: String? = null, + singleLine: Boolean = true, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + capitalization: KeyboardCapitalization = KeyboardCapitalization.Sentences, + keyboardType: KeyboardType = KeyboardType.Text, + imeAction: ImeAction = ImeAction.Done, + onIme: () -> Unit = {}, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = MaterialTheme.typography.bodyLarge, + suffix: String? = null, + trailing: (@Composable () -> Unit)? = null, +) { + val interaction = remember { MutableInteractionSource() } + val focused by interaction.collectIsFocusedAsState() + val scheme = MaterialTheme.colorScheme + val shape = KvittTheme.shapes.field + val outline = when { + error != null -> BorderStroke(KvittDimens.fieldFocusOutline, scheme.error) + focused && !readOnly -> BorderStroke(KvittDimens.fieldFocusOutline, scheme.primary) + readOnly || !enabled -> BorderStroke(KvittDimens.exactFieldBorder, scheme.outlineVariant) + else -> null + } + val container = if (readOnly || !enabled) Color.Transparent else scheme.surfaceContainerHighest + Column(modifier.fillMaxWidth()) { + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth().then(if (outline != null) Modifier.border(outline, shape) else Modifier), + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = { Text(label) }, + placeholder = placeholder?.let { { Text(it) } }, + suffix = suffix?.let { { Text(it, style = MaterialTheme.typography.titleLarge, color = scheme.onSurfaceVariant) } }, + trailingIcon = trailing, + isError = error != null, + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType, imeAction = imeAction), + keyboardActions = KeyboardActions(onAny = { onIme() }), + interactionSource = interaction, + shape = shape, + colors = TextFieldDefaults.colors( + focusedContainerColor = container, + unfocusedContainerColor = container, + disabledContainerColor = Color.Transparent, + errorContainerColor = container, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + focusedLabelColor = scheme.primary, + unfocusedLabelColor = scheme.onSurfaceVariant, + errorSupportingTextColor = scheme.error, + ), + ) + // Supporting text sits under the field, outside the outline (design-spec §3.7). + if (error != null) { + Row( + Modifier.padding(start = KvittTheme.spacing.s4, top = KvittTheme.spacing.s1), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + KvittIconImage(KvittIcon.Error, null, filled = true, size = KvittDimens.iconHint, tint = scheme.error) + Text(error, style = MaterialTheme.typography.bodySmall, color = scheme.error) + } + } else if (supporting != null) { + Text(supporting, Modifier.padding(start = KvittTheme.spacing.s4, top = KvittTheme.spacing.s1), style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant) + } + } +} + +/** + * Connected buttons (design-spec §3.10 `AnalyticsFilterBar`, ratio presets, the full-split picker). The Expressive + * `ButtonGroup` is not in Material 3 1.4.0, so this is the stable fallback `SingleChoiceSegmentedButtonRow`, styled per + * the spec: min height 40, label-large weight 600, unselected `surface-container-high` / `on-surface-variant`, selected + * `secondary` / `on-secondary`, outer ends full, inner corners 8, no borders or check marks. A radio group named + * [groupLabel]. + */ +@Composable +fun ConnectedButtonGroup( + options: List, + selected: Int, + onSelect: (Int) -> Unit, + groupLabel: String, + modifier: Modifier = Modifier, + minHeight: Dp = KvittDimens.filterButtonMin, + spoken: List = options, + leading: (@Composable (Int) -> Unit)? = null, +) { + val scheme = MaterialTheme.colorScheme + val inner = KvittTheme.shapes.groupInnerRadius + SingleChoiceSegmentedButtonRow(modifier.semantics { contentDescription = groupLabel }) { + options.forEachIndexed { index, label -> + val first = index == 0 + val last = index == options.lastIndex + val isSelected = index == selected + val full = CornerSize(FULL_PERCENT) + val innerCorner = CornerSize(inner.dp) + val shape = if (isSelected) { + CircleShape + } else { + RoundedCornerShape( + topStart = if (first) full else innerCorner, + bottomStart = if (first) full else innerCorner, + topEnd = if (last) full else innerCorner, + bottomEnd = if (last) full else innerCorner, + ) + } + SegmentedButton( + selected = isSelected, + onClick = { onSelect(index) }, + shape = shape, + modifier = Modifier.heightIn(min = minHeight).semantics { contentDescription = spoken[index] }, + colors = SegmentedButtonDefaults.colors( + activeContainerColor = scheme.secondary, + activeContentColor = scheme.onSecondary, + inactiveContainerColor = scheme.surfaceContainerHigh, + inactiveContentColor = scheme.onSurfaceVariant, + activeBorderColor = Color.Transparent, + inactiveBorderColor = Color.Transparent, + ), + border = BorderStroke(KvittDimens.divider, Color.Transparent), + icon = {}, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + leading?.invoke(index) + Text(label, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), maxLines = 1) + } + } + } + } +} + +private const val FULL_PERCENT = 50 diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Ledger.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Ledger.kt new file mode 100644 index 0000000..c74f31b --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Ledger.kt @@ -0,0 +1,222 @@ +package se.hagstedt.kvitt.design.component + +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.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +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.heading +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +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.model.LedgerRow +import se.hagstedt.kvitt.model.User + +// The ledger of docs/ux/design-spec.md §3.5 (D-019; S-5, S-12, S-16). + +/** + * Month header "Oktober · 7 457,90 kr" (title-small, heading), the year appended when not the current year + * (`ledger_month_total`). [period] is `YYYY-MM`. + */ +@Composable +fun LedgerMonthHeader(period: String, householdSpendOre: Long, modifier: Modifier = Modifier) { + val label = DateFormatter.monthHeader(period, LocalKvittClock.current.today, kvittLanguage()) + Text( + stringResource(R.string.ledger_month_total, label, money(householdSpendOre)), + modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(start = KvittDimens.monthHeaderSide, end = KvittDimens.monthHeaderSide, top = KvittDimens.monthHeaderTop, bottom = KvittDimens.monthHeaderBottom) + .semantics { heading() }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) +} + +/** `cloud_upload` in `pending`; "Väntar på synk" when standalone, decorative inside a merged row. */ +@Composable +fun SyncPendingMarker(modifier: Modifier = Modifier, size: Dp = KvittDimens.pendingMarker, standalone: Boolean = true) { + KvittIconImage( + KvittIcon.CloudUpload, + contentDescription = if (standalone) stringResource(R.string.common_pending_sync) else null, + modifier = modifier, + size = size, + tint = KvittTheme.colors.pending, + ) +} + +@Composable +@ReadOnlyComposable +private fun spokenDate(isoDate: String): String = + DateFormatter.spokenDate(DateFormatter.parseDate(isoDate), LocalKvittClock.current.today, kvittLanguage()) + +private fun String.lowerFirst(): String = replaceFirstChar { it.lowercaseChar() } + +/** + * An expense as a segmented-group item (design-spec §3.5): category tile 40, description over the effect line, amount + * over the date with the pending marker. At ≥ 1.5× font scale the amount column moves under the text. One node: + * "ICA Maxi Lindhagen, Mat & hushåll, 1 247,60 kronor, 16 oktober, du lånade 623,80 kronor, väntar på synk". + */ +@Composable +fun ExpenseRow(item: SegmentedItemScope, row: LedgerRow.ExpenseRow, onClick: () -> Unit, modifier: Modifier = Modifier) { + val separator = stringResource(R.string.common_separator_comma) + val spoken = listOfNotNull( + row.description, + categoryName(row.category), + spokenMoney(row.amountOre), + spokenDate(row.date), + effectSpoken(row.effect).lowerFirst(), + if (row.pending) stringResource(R.string.common_pending_sync).lowerFirst() else null, + ).joinToString(separator) + LedgerItem(item, onClick, spoken, modifier, fill = item.fill, tile = { CategoryTile(row.category, KvittDimens.tileLedger) }) { large -> + Column(if (large) Modifier.fillMaxWidth() else Modifier.weight(1f)) { + Text(row.description, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + EffectLine(row.effect) + if (large) AmountColumn(row.amountOre, row.date, row.pending, Alignment.Start) + } + if (!large) AmountColumn(row.amountOre, row.date, row.pending, Alignment.End) + } +} + +/** + * A settlement as a group item (design-spec §3.5): `settlement-row` tint, 40 dp `primary-container` circle with + * `handshake`, "Anna betalade dig 800,00 kr · 2 konton" over [typeLabel] ("Betalning", a feature string), date right. + * One node "Anna betalade dig 800,00 kronor, 2 konton, 6 oktober". + */ +@Composable +fun SettlementRow(item: SegmentedItemScope, row: LedgerRow.SettlementRow, typeLabel: String, onClick: () -> Unit, modifier: Modifier = Modifier) { + val household = LocalKvittHousehold.current + val accounts = pluralStringResource(R.plurals.ledger_settlement_accounts, row.accountCount, row.accountCount) + val title = settlementSentence(row.from, row.to, money(row.amountOre), household.isViewer(row.to.id)) + val dot = stringResource(R.string.common_separator_dot) + val separator = stringResource(R.string.common_separator_comma) + val spoken = listOfNotNull( + settlementSentence(row.from, row.to, spokenMoney(row.amountOre), household.isViewer(row.to.id)), + if (row.accountCount > 1) accounts else null, + spokenDate(row.date), + if (row.pending) stringResource(R.string.common_pending_sync).lowerFirst() else null, + ).joinToString(separator) + LedgerItem(item, onClick, spoken, modifier, fill = KvittTheme.colors.settlementRow, tile = { SettlementTile(KvittDimens.tileLedger) }) { large -> + Column(if (large) Modifier.fillMaxWidth() else Modifier.weight(1f)) { + Text( + if (row.accountCount > 1) title + dot + accounts else title, + style = MaterialTheme.typography.titleMedium, + maxLines = if (large) 3 else 2, + overflow = TextOverflow.Ellipsis, + ) + Text(typeLabel, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (large) DateLine(row.date, row.pending) + } + if (!large) DateLine(row.date, row.pending) + } +} + +/** "Anna betalade dig 800,00 kr" / "Du betalade Anna 800,00 kr" (`ledger_settlement_*`). */ +@Composable +@ReadOnlyComposable +fun settlementSentence(from: User, to: User, amount: String, toViewer: Boolean): String = + if (toViewer) stringResource(R.string.ledger_settlement_partner_paid_you, from.displayName, amount) + else stringResource(R.string.ledger_settlement_you_paid_partner, to.displayName, amount) + +/** The settlement tile: a circle in `primary-container` with a filled `handshake` (design-spec §3.5). */ +@Composable +fun SettlementTile(size: Dp, modifier: Modifier = Modifier) { + Box(modifier.size(size).background(MaterialTheme.colorScheme.primaryContainer, CircleShape).clearAndSetSemantics { }, contentAlignment = Alignment.Center) { + KvittIconImage(KvittIcon.Handshake, null, filled = true, size = size * KvittDimens.TILE_ICON_RATIO, tint = MaterialTheme.colorScheme.onPrimaryContainer) + } +} + +@Composable +private fun LedgerItem( + item: SegmentedItemScope, + onClick: () -> Unit, + spoken: String, + modifier: Modifier, + fill: androidx.compose.ui.graphics.Color, + tile: @Composable () -> Unit, + content: @Composable androidx.compose.foundation.layout.RowScope.(large: Boolean) -> Unit, +) { + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + Row( + modifier + .fillMaxWidth() + .clip(item.shape) + .background(fill) + .clickable(onClick = onClick) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button } + .heightIn(min = KvittTheme.spacing.ledgerRowMin) + .padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical), + verticalAlignment = if (large) Alignment.Top else Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap), + ) { + tile() + content(large) + } +} + +@Composable +private fun AmountColumn(amountOre: Long, date: String, pending: Boolean, alignment: Alignment.Horizontal) { + Column(horizontalAlignment = alignment) { + MoneyText(amountOre, AmountStyle.Small, AmountTone.Neutral, textAlign = if (alignment == Alignment.End) TextAlign.End else TextAlign.Start) + DateLine(date, pending) + } +} + +@Composable +private fun DateLine(date: String, pending: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + if (pending) SyncPendingMarker(standalone = false) + Text(listDate(date), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * A month of the ledger: the header and a segmented group of expense and settlement rows in the given order + * (AC-8.10). [settlementLabel] is the feature's "Betalning". + */ +@Composable +fun LedgerMonthGroup( + period: String, + householdSpendOre: Long, + rows: List, + settlementLabel: String, + onExpense: (LedgerRow.ExpenseRow) -> Unit, + onSettlement: (LedgerRow.SettlementRow) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + LedgerMonthHeader(period, householdSpendOre) + SegmentedGroup(rows.size) { item -> + when (val row = rows[item.index]) { + is LedgerRow.ExpenseRow -> ExpenseRow(item, row, onClick = { onExpense(row) }) + is LedgerRow.SettlementRow -> SettlementRow(item, row, settlementLabel, onClick = { onSettlement(row) }) + } + } + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Money.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Money.kt new file mode 100644 index 0000000..6914346 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Money.kt @@ -0,0 +1,185 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.model.ExpenseEffect +import se.hagstedt.kvitt.model.User + +/** The four amount styles of design-spec §2.7. */ +enum class AmountStyle { Hero, Large, Medium, Small } + +/** Colour of an amount: neutral, owed (partner owes viewer), owing (viewer owes), muted, settled (§3.2). */ +enum class AmountTone { Neutral, Owed, Owing, Muted, Settled } + +/** The colour of [tone]. */ +@Composable +@ReadOnlyComposable +fun AmountTone.color(): Color = when (this) { + AmountTone.Neutral -> MaterialTheme.colorScheme.onSurface + AmountTone.Owed -> KvittTheme.colors.owed + AmountTone.Owing -> KvittTheme.colors.owing + AmountTone.Muted -> MaterialTheme.colorScheme.onSurfaceVariant + AmountTone.Settled -> KvittTheme.colors.settled +} + +/** Tone of a viewer's net balance (§9.3): positive owed, negative owing, zero settled. */ +fun toneOf(netOre: Long): AmountTone = when { + netOre > 0 -> AmountTone.Owed + netOre < 0 -> AmountTone.Owing + else -> AmountTone.Settled +} + +@Composable +@ReadOnlyComposable +private fun AmountStyle.textStyle(): TextStyle = when (this) { + AmountStyle.Hero -> KvittTheme.typography.amountHero + AmountStyle.Large -> KvittTheme.typography.amountLarge + AmountStyle.Medium -> KvittTheme.typography.amountMedium + AmountStyle.Small -> KvittTheme.typography.amountSmall +} + +/** + * An amount in tabular figures (design-spec §3.2): `1 234,50 kr` / `SEK 1,234.50`, never a sign, never ellipsized. + * When the number and unit do not fit on one line the unit moves to its own line. The hero's unit is 24 sp. + * TalkBack reads [MoneyFormatter]'s spoken form ("1 234,50 kronor"). + */ +@Composable +fun MoneyText( + ore: Long, + style: AmountStyle, + tone: AmountTone, + modifier: Modifier = Modifier, + unit: Boolean = true, + color: Color = tone.color(), + textAlign: TextAlign = TextAlign.Start, +) { + val language = kvittLanguage() + val parts = MoneyFormatter.parts(ore, language) + val numberStyle = style.textStyle() + val unitStyle = if (style == AmountStyle.Hero) KvittTheme.typography.heroUnit else numberStyle + val spoken = spokenMoney(ore) + val semantics = modifier.clearAndSetSemantics { contentDescription = spoken } + if (!unit) { + Text(parts.number, semantics, color = color, style = numberStyle, maxLines = 1, softWrap = false, overflow = TextOverflow.Visible) + return + } + val gap = " " + val number: @Composable () -> Unit = { + Text(parts.number, color = color, style = numberStyle, maxLines = 1, softWrap = false) + } + val unitText: @Composable () -> Unit = { + Text(if (parts.unitFirst) parts.unit + gap else gap + parts.unit, color = color, style = unitStyle, maxLines = 1, softWrap = false) + } + Box(semantics) { + Layout(contents = if (parts.unitFirst) listOf(unitText, number) else listOf(number, unitText)) { measurables, constraints -> + val loose = constraints.copy(minWidth = 0, minHeight = 0) + val first = measurables[0].first().measure(loose.copy(maxWidth = Constraints.Infinity)) + val second = measurables[1].first().measure(loose.copy(maxWidth = Constraints.Infinity)) + val oneLine = first.width + second.width <= constraints.maxWidth + if (oneLine) { + val baseline = maxOf(first[FirstBaseline], second[FirstBaseline]) + val height = maxOf(baseline - first[FirstBaseline] + first.height, baseline - second[FirstBaseline] + second.height) + val width = (first.width + second.width).coerceAtLeast(constraints.minWidth) + val start = when (textAlign) { + TextAlign.End, TextAlign.Right -> width - first.width - second.width + TextAlign.Center -> (width - first.width - second.width) / 2 + else -> 0 + } + layout(width, height) { + first.place(start, baseline - first[FirstBaseline]) + second.place(start + first.width, baseline - second[FirstBaseline]) + } + } else { + val width = maxOf(first.width, second.width).coerceAtMost(constraints.maxWidth).coerceAtLeast(constraints.minWidth) + fun x(w: Int) = when (textAlign) { + TextAlign.End, TextAlign.Right -> width - w + TextAlign.Center -> (width - w) / 2 + else -> 0 + } + layout(width, first.height + second.height) { + first.place(x(first.width), 0) + second.place(x(second.width), first.height) + } + } + } + } +} + +/** The effect line of SPEC §9.2 (design-spec §3.2): words and colour from the viewer's side. */ +@Composable +fun EffectLine(effect: ExpenseEffect, modifier: Modifier = Modifier, style: TextStyle = MaterialTheme.typography.bodyMedium) { + Text(effectText(effect), modifier, color = effectColor(effect), style = style, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +/** "Du lånade ut 206,00 kr" and the other §9.2 lines. */ +@Composable +@ReadOnlyComposable +fun effectText(effect: ExpenseEffect): String = when (effect) { + is ExpenseEffect.Lent -> stringResource(R.string.effect_lent, money(effect.ore)) + is ExpenseEffect.Borrowed -> stringResource(R.string.effect_borrowed, money(effect.ore)) + ExpenseEffect.NotInvolved -> stringResource(R.string.effect_not_involved) + ExpenseEffect.PaidForYourself -> stringResource(R.string.effect_paid_for_yourself) +} + +/** The effect line as TalkBack reads it inside a row sentence (amounts spoken). */ +@Composable +@ReadOnlyComposable +fun effectSpoken(effect: ExpenseEffect): String = when (effect) { + is ExpenseEffect.Lent -> stringResource(R.string.effect_lent, spokenMoney(effect.ore)) + is ExpenseEffect.Borrowed -> stringResource(R.string.effect_borrowed, spokenMoney(effect.ore)) + ExpenseEffect.NotInvolved -> stringResource(R.string.effect_not_involved) + ExpenseEffect.PaidForYourself -> stringResource(R.string.effect_paid_for_yourself) +} + +@Composable +@ReadOnlyComposable +private fun effectColor(effect: ExpenseEffect): Color = when (effect) { + is ExpenseEffect.Lent -> KvittTheme.colors.owed + is ExpenseEffect.Borrowed -> KvittTheme.colors.owing + else -> MaterialTheme.colorScheme.onSurfaceVariant +} + +/** Where a balance is shown: the global hero ("Ni är kvitt") or an account ("Kvitt") (§9.3). */ +enum class BalanceScope { Global, Account } + +/** "Anna är skyldig dig" / "Du är skyldig Anna" / "Ni är kvitt" / "Kvitt" (design-spec §3.2, SPEC §9.3). */ +@Composable +@ReadOnlyComposable +fun balanceWording(netOre: Long, partner: User, scope: BalanceScope): String = when { + netOre > 0 -> stringResource(R.string.balance_partner_owes_you, partner.displayName) + netOre < 0 -> stringResource(R.string.balance_you_owe_partner, partner.displayName) + scope == BalanceScope.Global -> stringResource(R.string.balance_all_settled) + else -> stringResource(R.string.balance_account_settled) +} + +/** The wording with the amount, as one sentence for TalkBack: "Anna är skyldig dig 1 184,50 kronor". */ +@Composable +@ReadOnlyComposable +fun balanceSpoken(netOre: Long, partner: User, scope: BalanceScope): String = when { + netOre > 0 -> stringResource(R.string.balance_partner_owes_you_amount, partner.displayName, spokenMoney(netOre)) + netOre < 0 -> stringResource(R.string.balance_you_owe_partner_amount, partner.displayName, spokenMoney(netOre)) + else -> balanceWording(netOre, partner, scope) +} + +/** [balanceWording] as text in its colour (owed, owing or settled). */ +@Composable +fun BalanceWording(netOre: Long, partner: User, scope: BalanceScope, modifier: Modifier = Modifier, style: TextStyle = MaterialTheme.typography.titleMedium) { + Text(balanceWording(netOre, partner, scope), modifier, color = toneOf(netOre).color(), style = style) +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/People.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/People.kt new file mode 100644 index 0000000..ed8ae32 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/People.kt @@ -0,0 +1,159 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +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.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import coil3.compose.SubcomposeAsyncImage +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.design.theme.KvittFontCaps +import se.hagstedt.kvitt.design.theme.cappedTextSize +import se.hagstedt.kvitt.model.User + +/** Avatar outline: circle in rows and chips, the large shape (Sunny, fallback circle) standalone (design-spec §2.9). */ +enum class AvatarShape { Circle, Large } + +/** + * Supplies the Coil request model of a user's picture (E-11, keyed by user id and `pictureVersion`, AC-4.6). `:app` + * provides one that builds the authenticated request; the default has no pictures, so avatars show initials. + */ +fun interface AvatarPictures { + /** The model to load, or null when [user] has no picture. */ + fun model(user: User): Any? +} + +val LocalAvatarPictures = staticCompositionLocalOf { AvatarPictures { null } } + +/** The initials of AC-4.5: the first letter of the first two words of the display name, uppercase. */ +fun initialsOf(displayName: String): String = + displayName.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2).joinToString("") { word -> + val end = word.offsetByCodePoints(0, 1) + word.substring(0, end).uppercase() + } + +/** What the avatar shows while a picture uploads (S-2, S-14). */ +@Immutable +enum class AvatarState { Idle, Uploading } + +/** + * A person: picture via Coil, else initials on the slot colour (design-spec §3.3, §2.5). Initials are 44 % of the + * size (capped at 1.3× font scale) and show while the picture loads. [contentDescription] null when the name is + * printed beside it. + */ +@Composable +fun PersonAvatar( + user: User, + size: Dp, + modifier: Modifier = Modifier, + shape: AvatarShape = AvatarShape.Circle, + state: AvatarState = AvatarState.Idle, + contentDescription: String? = null, +) { + val outline: Shape = if (shape == AvatarShape.Large) KvittTheme.shapes.avatarLarge else CircleShape + val slot = LocalKvittHousehold.current.slotOf(user.id) + val uploadingLabel = stringResource(R.string.profile_picture_uploading).trimEnd('…') + val description = if (state == AvatarState.Uploading) uploadingLabel else contentDescription + val semantics = if (description != null) Modifier.clearAndSetSemantics { this.contentDescription = description } else Modifier.clearAndSetSemantics { } + Box(modifier.size(size).then(semantics), contentAlignment = Alignment.Center) { + val faded = if (state == AvatarState.Uploading) Modifier.alpha(KvittDimens.UPLOADING_ALPHA) else Modifier + val model = LocalAvatarPictures.current.model(user) + val initials: @Composable () -> Unit = { Initials(user, size, slot, outline) } + Box(faded.size(size).clip(outline), contentAlignment = Alignment.Center) { + if (model == null) { + initials() + } else { + SubcomposeAsyncImage( + model = model, + contentDescription = null, + contentScale = ContentScale.Crop, + loading = { initials() }, + error = { initials() }, + modifier = Modifier.size(size), + ) + } + } + if (state == AvatarState.Uploading) { + CircularProgressIndicator( + modifier = Modifier.size(size + KvittDimens.uploadRing * 2), + color = MaterialTheme.colorScheme.primary, + strokeWidth = KvittDimens.uploadRing, + ) + } + } +} + +@Composable +private fun Initials(user: User, size: Dp, slot: Int, shape: Shape) { + val colors = KvittTheme.colors + val density = LocalDensity.current + val fontSize = with(density) { (size * KvittDimens.INITIALS_RATIO).toSp() } + Box(Modifier.size(size).background(colors.avatar(slot), shape), contentAlignment = Alignment.Center) { + Text( + text = initialsOf(user.displayName), + color = colors.onAvatar(slot), + style = MaterialTheme.typography.titleMedium.copy( + fontSize = cappedTextSize(fontSize, KvittFontCaps.AVATAR_INITIALS), + lineHeight = cappedTextSize(fontSize, KvittFontCaps.AVATAR_INITIALS), + fontWeight = FontWeight.Bold, + ), + maxLines = 1, + ) + } +} + +/** + * The empty partner seat of S-3 (design-spec §3.3): circle with a dashed outline border, `surface-container-high` + * fill and `person_add`. TalkBack "Partner saknas" is the caller's [contentDescription] (feature string). + */ +@Composable +fun AvatarPlaceholder(size: Dp, modifier: Modifier = Modifier, contentDescription: String? = null) { + val fill = MaterialTheme.colorScheme.surfaceContainerHigh + val border = MaterialTheme.colorScheme.outline + val semantics = if (contentDescription != null) Modifier.clearAndSetSemantics { this.contentDescription = contentDescription } else Modifier.clearAndSetSemantics { } + Box(modifier.size(size).then(semantics), contentAlignment = Alignment.Center) { + Canvas(Modifier.size(size)) { + val stroke = KvittDimens.placeholderBorder.toPx() + drawCircle(fill) + drawRoundRect( + color = border, + topLeft = Offset(stroke / 2, stroke / 2), + size = Size(this.size.width - stroke, this.size.height - stroke), + cornerRadius = CornerRadius(this.size.width / 2), + style = Stroke(width = stroke, pathEffect = PathEffect.dashPathEffect(floatArrayOf(stroke * 2.5f, stroke * 2f))), + ) + } + KvittIconImage( + KvittIcon.PersonAdd, + contentDescription = null, + size = size * KvittDimens.PLACEHOLDER_ICON_RATIO, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt new file mode 100644 index 0000000..6179ef0 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt @@ -0,0 +1,271 @@ +package se.hagstedt.kvitt.design.component + +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.RowScope +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.size +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationItemIconPosition +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ShortNavigationBar +import androidx.compose.material3.ShortNavigationBarItem +import androidx.compose.material3.ShortNavigationBarItemDefaults +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +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.font.FontWeight +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.design.theme.KvittFontCaps +import se.hagstedt.kvitt.design.theme.capped + +// Scaffolding of docs/ux/design-spec.md §3.11: scaffold, top app bar with the wordmark, bottom navigation, FAB, +// invite code display and field. + +/** The navigation icon of a top app bar. */ +enum class TopBarNavigation { Back, Close, None } + +/** + * The top app bar (design-spec §3.11): 64 dp, `surface` (`surface-container` when [scrolled]), a 48 dp navigation + * button ("Tillbaka" / "Avbryt"), title title-large on one line, optional subtitle, actions. [title] null shows the + * wordmark (top destinations). + */ +@Composable +fun KvittTopAppBar( + title: String?, + modifier: Modifier = Modifier, + navigation: TopBarNavigation = TopBarNavigation.None, + onNavigation: () -> Unit = {}, + subtitle: String? = null, + scrolled: Boolean = false, + actions: @Composable RowScope.() -> Unit = {}, +) { + val scheme = MaterialTheme.colorScheme + Row( + modifier + .fillMaxWidth() + .background(if (scrolled) scheme.surfaceContainer else scheme.surface) + .heightIn(min = KvittTheme.spacing.topAppBarHeight) + .padding(horizontal = KvittTheme.spacing.s1), + verticalAlignment = Alignment.CenterVertically, + ) { + when (navigation) { + TopBarNavigation.Back -> IconButton(onNavigation) { KvittIconImage(KvittIcon.ArrowBack, stringResource(R.string.common_back)) } + TopBarNavigation.Close -> IconButton(onNavigation) { KvittIconImage(KvittIcon.Close, stringResource(R.string.common_cancel)) } + TopBarNavigation.None -> Unit + } + Column(Modifier.weight(1f).padding(start = if (navigation == TopBarNavigation.None) KvittTheme.spacing.s3 else KvittTheme.spacing.s1)) { + if (title == null) { + Wordmark() + } else { + Text(title, Modifier.semantics { heading() }, style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (subtitle != null) Text(subtitle, style = MaterialTheme.typography.bodySmall, color = scheme.onSurfaceVariant, maxLines = 1) + } + } + Row(verticalAlignment = Alignment.CenterVertically, content = actions) + } +} + +/** The wordmark: a 22 dp large-shape `primary` dot with `receipt_long` and "Kvitt" 24 sp 700 in `primary` (capped 1.5×). */ +@Composable +fun Wordmark(modifier: Modifier = Modifier) { + val name = stringResource(R.string.common_wordmark) + val scheme = MaterialTheme.colorScheme + Row(modifier.clearAndSetSemantics { contentDescription = name; heading() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + Box(Modifier.size(KvittDimens.wordmarkMark).background(scheme.primary, KvittTheme.shapes.avatarLarge), contentAlignment = Alignment.Center) { + KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.wordmarkIcon, tint = scheme.onPrimary) + } + Text(name, style = KvittTheme.typography.wordmark.capped(KvittFontCaps.WORDMARK), color = scheme.primary) + } +} + +/** The three destinations of the bottom navigation (SPEC §13.2). */ +enum class KvittDestination { Home, Analytics, Settings } + +/** + * The bottom navigation (design-spec §3.11): Material 3 1.4.0's stable `ShortNavigationBar` (64 dp), `surface-container`, + * indicator `primary-container`, filled icon when selected, labels label-medium capped at 1.6× (bold when selected). + */ +@Composable +fun KvittNavigationBar(selected: KvittDestination, onSelect: (KvittDestination) -> Unit, modifier: Modifier = Modifier) { + val scheme = MaterialTheme.colorScheme + val items = listOf( + Triple(KvittDestination.Home, KvittIcon.Home, R.string.nav_home), + Triple(KvittDestination.Analytics, KvittIcon.BarChart, R.string.nav_analytics), + Triple(KvittDestination.Settings, KvittIcon.Settings, R.string.nav_settings), + ) + val label = stringResource(R.string.nav_label) + ShortNavigationBar(modifier.semantics { contentDescription = label }, containerColor = scheme.surfaceContainer) { + items.forEach { (destination, icon, name) -> + val isSelected = destination == selected + ShortNavigationBarItem( + selected = isSelected, + onClick = { onSelect(destination) }, + icon = { KvittIconImage(icon, null, filled = isSelected) }, + label = { + Text( + stringResource(name), + style = MaterialTheme.typography.labelMedium.capped(KvittFontCaps.NAVIGATION_LABEL).let { if (isSelected) it.copy(fontWeight = FontWeight.Bold) else it }, + maxLines = 1, + ) + }, + iconPosition = NavigationItemIconPosition.Top, + colors = ShortNavigationBarItemDefaults.colors( + selectedIconColor = scheme.onPrimaryContainer, + selectedTextColor = scheme.onSurface, + selectedIndicatorColor = scheme.primaryContainer, + unselectedIconColor = scheme.onSurfaceVariant, + unselectedTextColor = scheme.onSurfaceVariant, + ), + ) + } + } +} + +/** + * "Lägg till utgift" (design-spec §3.11): 56 dp extended FAB, radius 20, `primary`; icon-only while [expanded] is false + * and at ≥ 1.5× font scale; TalkBack reads [text] in both forms. The Expressive medium FAB is not in Material 3 1.4.0. + */ +@Composable +fun AddExpenseFab(onClick: () -> Unit, modifier: Modifier = Modifier, expanded: Boolean = true, text: String = stringResource(R.string.fab_add_expense), enabled: Boolean = true) { + val scheme = MaterialTheme.colorScheme + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + val container = if (enabled) scheme.primary else scheme.onSurface.copy(alpha = KvittDimens.DISABLED_CONTAINER_ALPHA) + val content = if (enabled) scheme.onPrimary else scheme.onSurface.copy(alpha = KvittDimens.DISABLED_CONTENT_ALPHA) + val click = if (enabled) onClick else ({}) + val elevation = if (enabled) FloatingActionButtonDefaults.elevation() else FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp) + if (expanded && !large) { + ExtendedFloatingActionButton( + onClick = click, + modifier = modifier.height(KvittDimens.fabHeight), + shape = KvittTheme.shapes.fab, + containerColor = container, + contentColor = content, + elevation = elevation, + icon = { KvittIconImage(KvittIcon.Add, null) }, + text = { Text(text, style = KvittTheme.typography.fab) }, + ) + } else { + FloatingActionButton(click, modifier.size(KvittDimens.fabHeight), shape = KvittTheme.shapes.fab, containerColor = container, contentColor = content, elevation = elevation) { + KvittIconImage(KvittIcon.Add, text) + } + } +} + +/** + * Edge-to-edge scaffold (design-spec §3.11): top bar, content, optional FAB and bottom bar, snackbars above the FAB and + * navigation; the status-bar inset is handled by the app bar. + */ +@Composable +fun KvittScaffold( + modifier: Modifier = Modifier, + topBar: @Composable () -> Unit = {}, + bottomBar: @Composable () -> Unit = {}, + floatingActionButton: @Composable () -> Unit = {}, + snackbarHostState: SnackbarHostState? = null, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + modifier, + topBar = topBar, + bottomBar = bottomBar, + floatingActionButton = floatingActionButton, + snackbarHost = { + if (snackbarHostState != null) SnackbarHost(snackbarHostState) { data -> KvittSnackbar(data.visuals.message, Modifier.padding(KvittTheme.spacing.s4), data.visuals.actionLabel, { data.performAction() }) } + }, + containerColor = MaterialTheme.colorScheme.surface, + content = content, + ) +} + +/** Invite code normalisation (SPEC §8.3): uppercase, drop `-` and spaces, `O` → `0`, `I`/`L` → `1`, at most 12. */ +fun normalizeInviteCode(text: String): String = text.uppercase() + .filter { it != '-' && !it.isWhitespace() } + .map { when (it) { 'O' -> '0'; 'I', 'L' -> '1'; else -> it } } + .filter { it in INVITE_ALPHABET } + .joinToString("") + .take(INVITE_LENGTH) + +/** The code in groups of four: "7K3M-QX9D-2HPA". */ +fun groupInviteCode(normalized: String): String = normalized.chunked(4).joinToString("-") + +const val INVITE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" +const val INVITE_LENGTH = 12 + +/** + * The created invite code (design-spec §3.11, S-21): three groups of four on `surface` chips, 24 / 32 sp weight 700, + * selectable; TalkBack spells it group by group "7 K 3 M, Q X 9 D, 2 H P A". + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun InviteCodeDisplay(code: String, modifier: Modifier = Modifier) { + val groups = normalizeInviteCode(code).chunked(4) + val spoken = groups.joinToString(stringResource(R.string.common_separator_comma)) { it.toList().joinToString(" ") } + SelectionContainer(modifier.semantics { contentDescription = spoken }) { + FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + groups.forEach { group -> + Text( + group, + Modifier.background(MaterialTheme.colorScheme.surface, KvittTheme.shapes.inviteChip).padding(horizontal = KvittDimens.inviteChipHorizontal, vertical = KvittDimens.inviteChipVertical), + style = KvittTheme.typography.inviteCode, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +/** + * The invite code field (design-spec §3.11, S-1): filled field "Inbjudningskod", value title-large, uppercase, + * normalised and grouped while typing; a trailing [pasteLabel] text button ("Klistra in", a feature string). + */ +@Composable +fun InviteCodeField(value: String, onValueChange: (String) -> Unit, pasteLabel: String, onPaste: () -> Unit, modifier: Modifier = Modifier, error: String? = null, enabled: Boolean = true) { + KvittTextField( + value = groupInviteCode(normalizeInviteCode(value)), + onValueChange = { onValueChange(normalizeInviteCode(it)) }, + label = stringResource(R.string.common_invite_code), + modifier = modifier, + error = error, + capitalization = KeyboardCapitalization.Characters, + keyboardType = KeyboardType.Ascii, + textStyle = MaterialTheme.typography.titleLarge, + enabled = enabled, + readOnly = !enabled, + trailing = if (enabled) { + { KvittButton(pasteLabel, onPaste, kind = ButtonKind.Text, icon = KvittIcon.ContentPaste) } + } else { + null + }, + ) +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Settle.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Settle.kt new file mode 100644 index 0000000..a769cf3 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Settle.kt @@ -0,0 +1,195 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +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.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.AllocationLine +import se.hagstedt.kvitt.model.User + +// Settle-up components of docs/ux/design-spec.md §3.8 (S-9, S-10). Feature words ("Riktning", "Byt riktning", +// "Skuld …", "Kvar att fördela", the over-settle warning) come from `:feature:settle` as resolved strings. + +/** Words of [DirectionCard]: label "Riktning", the sentence "Du betalar Anna", the swap button's TalkBack name. */ +data class DirectionWords(val label: String, val sentence: String, val swap: String) + +/** + * The direction card (design-spec §3.8): payer avatar 40 → recipient avatar 40, "Riktning" over the sentence, and a + * tonal swap button. TalkBack: card text "Riktning: du betalar Anna" plus the button. + */ +@Composable +fun DirectionCard(payer: User, recipient: User, words: DirectionWords, onSwap: () -> Unit, modifier: Modifier = Modifier) { + val spoken = "${words.label}: ${words.sentence.replaceFirstChar { it.lowercaseChar() }}" + KvittCard(modifier.fillMaxWidth(), contentPadding = PaddingValues(start = KvittDimens.directionStart, end = KvittDimens.directionPadding, top = KvittDimens.directionPadding, bottom = KvittDimens.directionPadding)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + Row(Modifier.weight(1f).clearAndSetSemantics { contentDescription = spoken }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + PersonAvatar(payer, KvittDimens.avatarDirection, shape = AvatarShape.Large) + KvittIconImage(KvittIcon.ArrowForward, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + PersonAvatar(recipient, KvittDimens.avatarDirection, shape = AvatarShape.Large) + Column(Modifier.weight(1f)) { + Text(words.label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(words.sentence, style = MaterialTheme.typography.titleMedium) + } + } + FilledTonalIconButton(onSwap, Modifier.heightIn(min = KvittTheme.spacing.touchTarget)) { + KvittIconImage(KvittIcon.SwapHoriz, words.swap) + } + } + } +} + +/** Words of one [AllocationRow]: the position line ("Skuld 3 084,50 kr" or a balance), the field's TalkBack label, remove label and the over-settle warning. */ +data class AllocationWords(val position: String, val fieldLabel: String, val remove: String, val overSettle: String) + +/** + * One allocation as a group item (design-spec §3.8): tile 40, name over the payer's position, an amount field + * (148 dp, radius 14, right-aligned `amount-medium` + unit; system decimal keyboard) and a remove button. [fixed] + * shows the amount as text without remove. Over-settle: a 2 dp `warning` border and the warning strip. At ≥ 1.5× the + * field takes its own full-width line. + */ +@Composable +fun AllocationRow( + item: SegmentedItemScope, + line: AllocationLine, + colorIndex: Int, + amountText: String, + words: AllocationWords, + onAmount: (String) -> Unit, + onRemove: () -> Unit, + modifier: Modifier = Modifier, + fixed: Boolean = false, +) { + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + val colors = KvittTheme.colors + Column( + modifier + .fillMaxWidth() + .background(item.fill, item.shape) + .padding(horizontal = KvittDimens.allocationHorizontal, vertical = KvittDimens.allocationVertical), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + AccountTile(line.account.name, colorIndex, size = KvittDimens.tileLedger, muted = line.account.archived) + Column(Modifier.weight(1f).padding(start = KvittTheme.spacing.s1).semantics(mergeDescendants = true) {}) { + Text(line.account.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(words.position, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (!large) AmountCell(line, amountText, words.fieldLabel, fixed, onAmount, Modifier.width(KvittDimens.allocationField)) + if (!fixed) { + IconButton(onRemove) { KvittIconImage(KvittIcon.Close, words.remove, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } + if (large) AmountCell(line, amountText, words.fieldLabel, fixed, onAmount, Modifier.fillMaxWidth()) + if (line.overSettles) { + Row( + Modifier.fillMaxWidth().background(colors.warningContainer, KvittTheme.shapes.strip).padding(horizontal = KvittDimens.stripHorizontal, vertical = KvittDimens.stripVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + KvittIconImage(KvittIcon.Warning, null, filled = true, size = KvittDimens.iconButton, tint = colors.onWarningContainer) + Text(words.overSettle, style = MaterialTheme.typography.bodyMedium, color = colors.onWarningContainer) + } + } + } +} + +@Composable +private fun AmountCell(line: AllocationLine, text: String, label: String, fixed: Boolean, onAmount: (String) -> Unit, modifier: Modifier) { + val scheme = MaterialTheme.colorScheme + val unit = MoneyFormatter.unit(kvittLanguage()) + if (fixed) { + Box(modifier, contentAlignment = Alignment.CenterEnd) { MoneyText(line.amountOre, AmountStyle.Medium, AmountTone.Neutral, textAlign = TextAlign.End) } + return + } + val shape = KvittTheme.shapes.allocationField + val border = if (line.overSettles) Modifier.border(KvittDimens.overSettleBorder, KvittTheme.colors.warning, shape) else Modifier + Row( + modifier + .heightIn(min = KvittTheme.spacing.touchTarget) + .background(scheme.surfaceContainerHighest, shape) + .then(border) + .padding(horizontal = KvittTheme.spacing.s4, vertical = KvittDimens.allocationFieldTop), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + BasicTextField( + text, + onAmount, + Modifier.weight(1f).semantics { contentDescription = label }, + textStyle = KvittTheme.typography.amountMedium.copy(color = scheme.onSurface, textAlign = TextAlign.End), + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + cursorBrush = SolidColor(scheme.primary), + ) + Text(unit, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant) + } +} + +/** + * Remaining to allocate (design-spec §3.8): zero → `surface-container-high` with a check in `owed`; otherwise + * `warning-container` with `error`, "250,00 kr" or "250,00 kr för mycket". [label] is "Kvar att fördela". Polite live + * region; TalkBack "Kvar att fördela 250,00 kronor". + */ +@Composable +fun RemainingIndicator(remainingOre: Long, label: String, modifier: Modifier = Modifier) { + val colors = KvittTheme.colors + val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE + val zero = remainingOre == 0L + val amount = if (remainingOre < 0) stringResource(R.string.common_too_much, money(remainingOre)) else money(remainingOre) + val spokenAmount = if (remainingOre < 0) stringResource(R.string.common_too_much, spokenMoney(remainingOre)) else spokenMoney(remainingOre) + val spoken = "$label $spokenAmount" + val content = if (zero) MaterialTheme.colorScheme.onSurface else colors.onWarningContainer + Row( + modifier + .fillMaxWidth() + .background(if (zero) MaterialTheme.colorScheme.surfaceContainerHigh else colors.warningContainer, KvittTheme.shapes.menu) + .clearAndSetSemantics { contentDescription = spoken; liveRegion = LiveRegionMode.Polite } + .padding(horizontal = KvittDimens.remainingHorizontal, vertical = KvittDimens.remainingVertical), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittDimens.remainingVertical), + ) { + KvittIconImage(if (zero) KvittIcon.CheckCircle else KvittIcon.Error, null, filled = true, tint = if (zero) colors.owed else colors.warning) + if (large) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.titleSmall, color = content) + Text(amount, style = KvittTheme.typography.amountMedium, color = content) + } + } else { + Text(label, Modifier.weight(1f), style = MaterialTheme.typography.titleSmall, color = content) + Text(amount, style = KvittTheme.typography.amountMedium, color = content) + } + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Split.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Split.kt new file mode 100644 index 0000000..267247d --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Split.kt @@ -0,0 +1,458 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.IconButton +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.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.Share +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.User +import se.hagstedt.kvitt.model.UserId +import kotlin.math.roundToInt + +// The split parts of the expense editor (design-spec §3.7): split bar, slider, preview and the split section. + +/** + * Two rounded segments, the viewer (left) in the viewer's avatar colour and the partner in theirs, widths in + * proportion, separated by 3 dp (6 dp with a knob); height 16 (8 [thin]). [error] paints the left segment `error`. + * Decorative unless [contentDescription] is given ("Du 60 procent, Anna 40 procent"). + */ +@Composable +fun SplitBar( + viewerPercent: Float, + modifier: Modifier = Modifier, + thin: Boolean = false, + error: Boolean = false, + knob: Boolean = false, + contentDescription: String? = null, +) { + val household = LocalKvittHousehold.current + val colors = KvittTheme.colors + val viewerSlot = household.viewer?.let { household.slotOf(it) } ?: 1 + val left = if (error) MaterialTheme.colorScheme.error else colors.avatar(viewerSlot) + val right = colors.avatar(if (viewerSlot == 1) 2 else 1) + val knobColor = MaterialTheme.colorScheme.onSurface + val height = if (thin) KvittDimens.splitBarThin else KvittDimens.splitBar + val semantics = if (contentDescription != null) Modifier.clearAndSetSemantics { this.contentDescription = contentDescription } else Modifier.clearAndSetSemantics { } + Canvas(modifier.fillMaxWidth().height(if (knob) KvittDimens.knobHeight else height).then(semantics)) { + val fraction = (viewerPercent / 100f).coerceIn(0f, 1f) + val barHeight = height.toPx() + val top = (size.height - barHeight) / 2 + val gap = (if (knob) KvittDimens.splitGapKnob else KvittDimens.splitGap).toPx() + val radius = CornerRadius(barHeight / 2) + val split = size.width * fraction + when { + fraction <= 0f -> drawRoundRect(right, Offset(0f, top), Size(size.width, barHeight), radius) + fraction >= 1f -> drawRoundRect(left, Offset(0f, top), Size(size.width, barHeight), radius) + else -> { + drawRoundRect(left, Offset(0f, top), Size((split - gap / 2).coerceAtLeast(barHeight), barHeight), radius) + val start = (split + gap / 2).coerceAtMost(size.width - barHeight) + drawRoundRect(right, Offset(start, top), Size(size.width - start, barHeight), radius) + } + } + if (knob) { + val w = KvittDimens.knobWidth.toPx() + drawRoundRect(knobColor, Offset(split - w / 2, 0f), Size(w, size.height), CornerRadius(w / 2)) + } + } +} + +/** "Jacob 60 procent, Anna 40 procent" (`editor_ratio_spoken`); percents without decimals when whole. */ +@Composable +fun ratioSpoken(left: User, leftBp: Int, right: User): String { + val language = kvittLanguage() + return stringResource( + R.string.editor_ratio_spoken, + personName(left), + MoneyFormatter.basisPoints(leftBp, language), + personName(right), + MoneyFormatter.basisPoints(10_000 - leftBp, language), + ) +} + +/** + * The ratio slider (design-spec §3.7, S-6 and S-7): a 16 dp split bar with a 4 × 44 dp knob at the split point; drags in + * steps of one percentage point with a haptic tick at 50. TalkBack: a slider labelled "Jacobs andel" with the value + * "Jacob 60 procent, Anna 40 procent" and ± 1 adjustments. [viewerPercent] is a whole percent 0–100. + */ +@Composable +fun SplitSlider(viewer: User, partner: User, viewerPercent: Int, onChange: (Int) -> Unit, modifier: Modifier = Modifier) { + val haptics = LocalHapticFeedback.current + val label = stringResource(R.string.editor_ratio_slider, viewer.displayName) + val value = ratioSpoken(viewer, viewerPercent * 100, partner) + fun set(percent: Int) { + val clamped = percent.coerceIn(0, 100) + if (clamped != viewerPercent) { + if (clamped == 50) haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + onChange(clamped) + } + } + var width = 1f + Box( + modifier + .fillMaxWidth() + .semantics { + contentDescription = label + stateDescription = value + progressBarRangeInfo = ProgressBarRangeInfo(viewerPercent.toFloat(), 0f..100f, steps = 99) + setProgress { target -> set(target.roundToInt()); true } + } + .pointerInput(viewerPercent) { + width = size.width.toFloat() + detectTapGestures { offset -> set((offset.x / width * 100).roundToInt()) } + } + .pointerInput(Unit) { + detectHorizontalDragGestures { change, _ -> set((change.position.x / size.width * 100).roundToInt()) } + }, + ) { + SplitBar(viewerPercent.toFloat(), knob = true) + } +} + +/** + * The live preview (design-spec §3.7): a 72 dp thin split bar and "Du **206,00 kr** · Anna **206,00 kr**". Data only + * from `ExpenseRepository.preview` (the UI never computes shares); nothing while [shares] is null. [showBar] false + * drops the small bar where a full-width bar is drawn above (the S-8 split fact). + */ +@Composable +fun SplitPreview(shares: List?, me: User, partner: User, modifier: Modifier = Modifier, showBar: Boolean = true) { + if (shares == null) return + val total = shares.sumOf { it.shareOre } + val mine = shares.firstOrNull { it.userId == me.id }?.shareOre ?: 0L + val viewerPercent = if (total == 0L) 50f else mine * 100f / total + val spoken = shareText(shares, me, partner, spoken = true) + val byUser = shares.associateBy { it.userId } + val dot = stringResource(R.string.common_separator_dot) + val text = buildAnnotatedString { + listOf(me, partner).forEachIndexed { index, user -> + val share = byUser[user.id] ?: return@forEachIndexed + if (index > 0) append(dot) + append(personName(user) + " ") + withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontFeatureSettings = "tnum")) { append(money(share.shareOre)) } + } + } + Row( + modifier.clearAndSetSemantics { contentDescription = spoken }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s4), + ) { + if (showBar) SplitBar(viewerPercent, Modifier.width(KvittDimens.previewBar), thin = true) + Text(text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } +} + +/** The four split modes with their labels: [defaultLabel] is "Kontots standard 50/50" with the account's live ratio. */ +@Composable +fun splitModeLabel(mode: SplitMode, defaultLabel: String): String = when (mode) { + SplitMode.DEFAULT -> defaultLabel + SplitMode.RATIO -> stringResource(R.string.editor_split_mode_ratio) + SplitMode.EXACT -> stringResource(R.string.editor_split_mode_exact) + SplitMode.FULL -> stringResource(R.string.editor_split_mode_full) +} + +/** "Kontots standard 50/50" from the viewer's and the partner's percent (`editor_split_mode_default`). */ +@Composable +fun defaultModeLabel(viewerPercent: Int): String = + stringResource(R.string.editor_split_mode_default, stringResource(R.string.common_ratio, viewerPercent.toString(), (100 - viewerPercent).toString())) + +/** + * The open split section (design-spec §3.7): a flat section with the header "Delning" and a collapse button, the four + * mode chips as a radio group, then [body]: the split bar and preview for `default`, or [RatioEditor], + * [ExactSplitEditor] or [FullSplitPicker]. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SplitSection( + mode: SplitMode, + defaultLabel: String, + onMode: (SplitMode) -> Unit, + onCollapse: () -> Unit, + modifier: Modifier = Modifier, + body: @Composable () -> Unit, +) { + val scheme = MaterialTheme.colorScheme + val group = stringResource(R.string.editor_split) + val collapse = stringResource(R.string.editor_split_collapse) + Column( + modifier.fillMaxWidth().background(scheme.surfaceContainerLow, KvittTheme.shapes.card).padding(KvittTheme.spacing.s4), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(group, Modifier.weight(1f), style = MaterialTheme.typography.titleSmall) + IconButton(onCollapse) { KvittIconImage(KvittIcon.ExpandLess, collapse, tint = scheme.onSurfaceVariant) } + } + FlowRow( + Modifier.selectableGroup().semantics { contentDescription = group }, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + SplitMode.entries.forEach { m -> + val selected = m == mode + Row( + Modifier + .clip(CircleShape) + .background(if (selected) scheme.secondaryContainer else scheme.surfaceContainerHigh) + .selectable(selected, role = Role.RadioButton) { onMode(m) } + .padding(horizontal = KvittTheme.spacing.s4, vertical = KvittTheme.spacing.s2), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + if (selected) KvittIconImage(KvittIcon.Check, null, size = KvittDimens.iconChip, tint = scheme.onSecondaryContainer) + Text(splitModeLabel(m, defaultLabel), style = KvittTheme.typography.chip.copy(fontWeight = FontWeight.Medium), color = if (selected) scheme.onSecondaryContainer else scheme.onSurface) + } + } + } + body() + } +} + +/** Which percent field of the ratio editor has the keypad's focus. */ +enum class RatioField { Viewer, Partner } + +/** + * Ratio mode (design-spec §3.7): the two names with avatars 32 at either end, the slider, two percent fields typed on + * the keypad (one sets the other to 100 minus it), presets 40/60 · 50/50 · 60/40 (viewer first), then the preview. + * [viewerText] and [partnerText] are the typed field texts; [preset] the selected preset index or -1. + */ +@Composable +fun RatioEditor( + me: User, + partner: User, + viewerPercent: Int, + viewerText: String, + partnerText: String, + focused: RatioField?, + onSlider: (Int) -> Unit, + onFocus: (RatioField) -> Unit, + onPreset: (Int) -> Unit, + shares: List?, + modifier: Modifier = Modifier, + error: String? = null, +) { + val presets = listOf(40, 50, 60) + val ratio = { v: Int -> "$v/${100 - v}" } + val presetSpoken = presets.map { v -> + ratioSpoken(me, v * 100, partner) + } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + Row(verticalAlignment = Alignment.CenterVertically) { + PersonAvatar(me, KvittDimens.avatarSplit) + Text(me.displayName, Modifier.weight(1f).padding(start = KvittTheme.spacing.s2), style = MaterialTheme.typography.titleSmall) + Text(partner.displayName, Modifier.padding(end = KvittTheme.spacing.s2), style = MaterialTheme.typography.titleSmall) + PersonAvatar(partner, KvittDimens.avatarSplit) + } + SplitSlider(me, partner, viewerPercent, onSlider) + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + PercentField(me.displayName, viewerText, focused == RatioField.Viewer, error, { onFocus(RatioField.Viewer) }, Modifier.weight(1f)) + PercentField(partner.displayName, partnerText, focused == RatioField.Partner, null, { onFocus(RatioField.Partner) }, Modifier.weight(1f)) + } + ConnectedButtonGroup( + presets.map(ratio), + presets.indexOf(viewerPercent), + { onPreset(presets[it]) }, + groupLabel = stringResource(R.string.editor_split_mode_ratio), + modifier = Modifier.fillMaxWidth(), + spoken = presetSpoken, + ) + SplitPreview(shares, me, partner) + } +} + +@Composable +private fun PercentField(name: String, text: String, focused: Boolean, error: String?, onFocus: () -> Unit, modifier: Modifier) { + val label = stringResource(R.string.editor_percent_of, name) + KeypadValueField(label, text, stringResource(R.string.editor_percent_suffix), focused, error, onFocus, modifier) +} + +/** + * A field typed on the in-app keypad (percent and exact-share fields): looks like [KvittTextField], shows [text] with a + * caret when [focused], a [suffix], and an error line. [readOnly] draws the transparent look with [trailingCaption]. + */ +@Composable +fun KeypadValueField( + label: String, + text: String, + suffix: String, + focused: Boolean, + error: String?, + onFocus: () -> Unit, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + trailingCaption: String? = null, +) { + val scheme = MaterialTheme.colorScheme + val shape = KvittTheme.shapes.field + val border = when { + error != null -> KvittDimens.fieldFocusOutline to scheme.error + focused -> KvittDimens.fieldFocusOutline to scheme.primary + readOnly -> KvittDimens.exactFieldBorder to scheme.outlineVariant + else -> null + } + val spoken = listOfNotNull(label, text.ifEmpty { null }, trailingCaption, error).joinToString(stringResource(R.string.common_separator_comma)) + Column(modifier) { + Row( + Modifier + .fillMaxWidth() + .clip(shape) + .background(if (readOnly) Color.Transparent else scheme.surfaceContainerHighest) + .then(if (border != null) Modifier.border(border.first, border.second, shape) else Modifier) + .clickable(onClick = onFocus) + .clearAndSetSemantics { contentDescription = spoken } + .padding(horizontal = KvittTheme.spacing.s4, vertical = KvittTheme.spacing.s3), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(label, style = MaterialTheme.typography.bodySmall, color = if (error != null) scheme.error else if (focused) scheme.primary else scheme.onSurfaceVariant) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(text, style = KvittTheme.typography.amountMedium, color = scheme.onSurface) + if (focused) Box(Modifier.width(KvittDimens.caretWidth / 2 + KvittDimens.divider).height(KvittTheme.spacing.s5).background(scheme.primary)) + } + } + if (trailingCaption != null) Text(trailingCaption, style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant) + else Text(suffix, style = MaterialTheme.typography.titleMedium, color = scheme.onSurfaceVariant) + } + if (error != null) { + Row(Modifier.padding(start = KvittTheme.spacing.s4, top = KvittTheme.spacing.s1), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittIconImage(KvittIcon.Error, null, filled = true, size = KvittDimens.iconHint, tint = scheme.error) + Text(error, style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), color = scheme.error) + } + } + } +} + +/** + * Exact mode (design-spec §3.7, AC-8.7): one row per person, avatar 40 and a money field. The [typed] person's field + * shows [typedText]; the other is read-only showing [derivedOre] ("—" while null) with "räknas ut". Tapping the + * read-only field makes it the typed one ([onTypedChange]). With [overOre] > 0 the typed field is in error and the + * error box "Jacob 1 200,00 kr av 1 060,00 kr" / "140,00 kr för mycket" shows. + */ +@Composable +fun ExactSplitEditor( + me: User, + partner: User, + typed: UserId, + typedText: String, + derivedOre: Long?, + focused: Boolean, + amountOre: Long?, + typedOre: Long?, + onTypedChange: (UserId) -> Unit, + onFocus: () -> Unit, + modifier: Modifier = Modifier, +) { + val scheme = MaterialTheme.colorScheme + val language = kvittLanguage() + val unit = MoneyFormatter.unit(language) + val over = if (amountOre != null && typedOre != null && typedOre > amountOre) typedOre - amountOre else 0L + val tooLarge = stringResource(R.string.error_exact_share_too_large) + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + listOf(me, partner).forEach { user -> + val isTyped = user.id == typed + Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + PersonAvatar(user, KvittDimens.avatarDirection, Modifier.padding(top = KvittTheme.spacing.s2)) + if (isTyped) { + KeypadValueField(user.displayName, typedText, unit, focused, if (over > 0) tooLarge else null, onFocus, Modifier.weight(1f)) + } else { + KeypadValueField( + user.displayName, + derivedOre?.let { MoneyFormatter.number(it, language) } ?: DASH, + unit, + focused = false, + error = null, + onFocus = { onTypedChange(user.id) }, + modifier = Modifier.weight(1f), + readOnly = true, + trailingCaption = stringResource(R.string.editor_split_exact_computed), + ) + } + } + } + if (over > 0 && amountOre != null && typedOre != null) { + val typedUser = if (typed == me.id) me else partner + Column( + Modifier.fillMaxWidth().background(scheme.errorContainer, KvittTheme.shapes.menu).padding(horizontal = KvittTheme.spacing.s4, vertical = KvittTheme.spacing.s3), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), + ) { + Text(stringResource(R.string.editor_split_exact_of, typedUser.displayName, money(typedOre), money(amountOre)), style = MaterialTheme.typography.bodyMedium, color = scheme.onErrorContainer) + Text(stringResource(R.string.common_too_much, money(over)), style = MaterialTheme.typography.titleSmall, color = scheme.onErrorContainer) + } + } + } +} + +private const val DASH = "—" + +/** + * Full mode (design-spec §3.7): "Vem står för hela beloppet?", a two-button connected group (avatar 28 + name each, + * min height 52), a 16 dp split bar fully in the bearer's colour, then the preview. + */ +@Composable +fun FullSplitPicker(me: User, partner: User, bearer: UserId, onBearer: (UserId) -> Unit, shares: List?, modifier: Modifier = Modifier) { + val people = listOf(me, partner) + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + Text(stringResource(R.string.editor_split_full_question), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + ConnectedButtonGroup( + people.map { personName(it) }, + people.indexOfFirst { it.id == bearer }, + { onBearer(people[it].id) }, + groupLabel = stringResource(R.string.editor_split_full_question), + modifier = Modifier.fillMaxWidth(), + minHeight = KvittDimens.fullPickerMinHeight, + spoken = people.map { stringResource(R.string.editor_split_full_on, personNameInSentence(it)) }, + leading = { PersonAvatar(people[it], KvittDimens.avatarFullPicker) }, + ) + SplitBar(if (bearer == me.id) 100f else 0f) + SplitPreview(shares, me, partner) + } +} + +/** The ratio a feature shows in the default label, from the account's default shares. */ +fun viewerPercentOf(defaultShares: Map, viewer: UserId): Int = defaultShares[viewer] ?: 50 + diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt new file mode 100644 index 0000000..4500133 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Sync.kt @@ -0,0 +1,255 @@ +package se.hagstedt.kvitt.design.component + +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.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.format.RelativeTime +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.SyncSummary +import se.hagstedt.kvitt.model.UpdateState +import kotlin.time.Instant + +// Sync line and banners of docs/ux/design-spec.md §3.6. + +/** Which look the sync pill takes (design-spec §3.6), decided by [syncPillVariant]. */ +enum class SyncPillVariant { Synced, Running, Offline, Rejected, ClockWrong, Failed } + +/** + * The pill's variant: the clock problem and refused changes win, then offline, a failed last attempt ([failed]: the + * repositories do not expose it yet, see the M6 report), running, else synced. + */ +fun syncPillVariant(summary: SyncSummary, online: Boolean, failed: Boolean = false): SyncPillVariant = when { + summary.clockSkew -> SyncPillVariant.ClockWrong + summary.rejectedCount > 0 -> SyncPillVariant.Rejected + !online -> SyncPillVariant.Offline + failed -> SyncPillVariant.Failed + summary.running || summary.lastSuccessAt == null -> SyncPillVariant.Running + else -> SyncPillVariant.Synced +} + +/** "nyss", "för 3 min sedan", "för 2 h sedan" or "16 okt. 14:00" for an RFC 3339 instant (SPEC §9.8). */ +@Composable +@ReadOnlyComposable +fun relativeTime(rfc3339: String): String { + val clock = LocalKvittClock.current + return when (val relative = DateFormatter.relative(Instant.parse(rfc3339), clock.now(), clock.zone, kvittLanguage())) { + RelativeTime.JustNow -> stringResource(R.string.time_just_now) + is RelativeTime.MinutesAgo -> stringResource(R.string.time_minutes_ago, relative.minutes) + is RelativeTime.HoursAgo -> stringResource(R.string.time_hours_ago, relative.hours) + is RelativeTime.At -> relative.text + } +} + +/** The visible text of the sync line, also used by S-13's "Synkstatus" subtitle. */ +@Composable +@ReadOnlyComposable +fun syncLineText(summary: SyncSummary, online: Boolean, failed: Boolean = false): String { + val dot = stringResource(R.string.common_separator_dot) + val pending = if (summary.pendingCount > 0) pluralStringResource(R.plurals.sync_pending, summary.pendingCount, summary.pendingCount) else null + return when (syncPillVariant(summary, online, failed)) { + SyncPillVariant.ClockWrong -> stringResource(R.string.sync_clock_wrong_short) + SyncPillVariant.Rejected -> pluralStringResource(R.plurals.sync_rejected, summary.rejectedCount, summary.rejectedCount) + SyncPillVariant.Offline -> listOfNotNull(stringResource(R.string.common_offline), pending).joinToString(dot) + SyncPillVariant.Failed -> stringResource(R.string.sync_failed_line) + SyncPillVariant.Running -> listOfNotNull(stringResource(R.string.sync_syncing), pending).joinToString(dot) + SyncPillVariant.Synced -> listOfNotNull(stringResource(R.string.sync_synced_relative, relativeTime(summary.lastSuccessAt.orEmpty())), pending).joinToString(dot) + } +} + +/** + * The centred sync pill of Home (design-spec §3.6): never a blocking banner; one button "Synkstatus: ". + * The caller refreshes it every minute by recomposing with the clock. + */ +@Composable +fun SyncStatusPill(summary: SyncSummary, online: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, failed: Boolean = false) { + val colors = KvittTheme.colors + val scheme = MaterialTheme.colorScheme + val variant = syncPillVariant(summary, online, failed) + val text = syncLineText(summary, online, failed) + val (fill, icon, iconTint) = when (variant) { + SyncPillVariant.Synced -> Triple(scheme.surfaceContainerHigh, KvittIcon.CloudDone, colors.owed) + SyncPillVariant.Running -> Triple(scheme.surfaceContainerHigh, KvittIcon.Sync, scheme.onSurfaceVariant) + SyncPillVariant.Offline -> Triple(colors.syncPillPending, KvittIcon.CloudOff, colors.pending) + SyncPillVariant.Rejected, SyncPillVariant.ClockWrong, SyncPillVariant.Failed -> Triple(colors.warningContainer, KvittIcon.Warning, colors.warning) + } + val filledIcon = variant == SyncPillVariant.Synced || icon == KvittIcon.Warning + val content = if (fill == colors.warningContainer) colors.onWarningContainer else scheme.onSurface + val spoken = stringResource(R.string.sync_line_spoken, text) + Row( + modifier + .heightIn(min = KvittTheme.spacing.touchTarget) + .clip(CircleShape) + .clickable(role = Role.Button, onClick = onClick) + .clearAndSetSemantics { contentDescription = spoken; role = Role.Button }, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + Modifier + .heightIn(min = KvittDimens.pillMinHeight) + .background(fill, CircleShape) + .padding(start = KvittDimens.pillStart, end = KvittDimens.pillEnd, top = KvittDimens.pillTop, bottom = KvittDimens.pillTop), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), + ) { + KvittIconImage(icon, null, filled = filledIcon, size = KvittDimens.iconChip, tint = iconTint) + Text(text, Modifier.weight(1f, fill = false), style = KvittTheme.typography.syncPill, color = content) + KvittIconImage(KvittIcon.ChevronRight, null, size = KvittDimens.iconChip, tint = scheme.onSurfaceVariant) + } + } +} + +/** Banner kinds of design-spec §3.6. */ +enum class BannerKind { Lantern, Error, Warning, Neutral } + +/** + * A banner: radius 24, padding 14 / 16 / 10, filled icon 24, title (title-small) and body (body-medium), actions + * right-aligned in the banner's content colour. Role status (polite live region; alert for Error). + */ +@Composable +fun Banner( + kind: BannerKind, + icon: KvittIcon, + title: String, + modifier: Modifier = Modifier, + body: String? = null, + iconTint: Color? = null, + progress: (@Composable () -> Unit)? = null, + actions: (@Composable RowScope.(content: Color) -> Unit)? = null, +) { + val scheme = MaterialTheme.colorScheme + val colors = KvittTheme.colors + val (fill, content) = when (kind) { + BannerKind.Lantern -> scheme.tertiaryContainer to scheme.onTertiaryContainer + BannerKind.Error -> scheme.errorContainer to scheme.onErrorContainer + BannerKind.Warning -> colors.warningContainer to colors.onWarningContainer + BannerKind.Neutral -> scheme.surfaceContainerHigh to scheme.onSurface + } + Column( + modifier + .fillMaxWidth() + .background(fill, KvittTheme.shapes.accountCard) + .padding(start = KvittDimens.bannerSide, end = KvittDimens.bannerSide, top = KvittDimens.bannerTop, bottom = KvittDimens.bannerBottom), + verticalArrangement = Arrangement.spacedBy(KvittDimens.bannerGap), + ) { + Row( + Modifier.semantics(mergeDescendants = true) { liveRegion = if (kind == BannerKind.Error) LiveRegionMode.Assertive else LiveRegionMode.Polite }, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), + ) { + KvittIconImage(icon, null, filled = true, size = KvittDimens.iconBanner, tint = iconTint ?: content) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { + Text(title, style = MaterialTheme.typography.titleSmall, color = content) + if (body != null) Text(body, style = MaterialTheme.typography.bodyMedium, color = content) + } + } + progress?.invoke() + if (actions != null) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End), verticalAlignment = Alignment.CenterVertically) { + actions(content) + } + } + } +} + +/** + * The in-app update banner (design-spec §3.6, S-17): Available (Lantern, "Uppdatera" disabled offline with "Kräver + * anslutning" as body), Downloading (progress, no actions), Failed (Error, "Försök igen"). NeedsInstallPermission looks + * like Available. Nothing for [UpdateState.None]. + */ +@Composable +fun UpdateBanner(state: UpdateState, online: Boolean, onUpdate: () -> Unit, onLater: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier) { + val language = kvittLanguage() + when (state) { + UpdateState.None -> Unit + is UpdateState.Available, is UpdateState.NeedsInstallPermission -> { + val info = if (state is UpdateState.Available) state.info else (state as UpdateState.NeedsInstallPermission).info + Banner( + BannerKind.Lantern, + KvittIcon.SystemUpdate, + stringResource(R.string.update_available, info.versionName), + modifier, + body = if (online) stringResource(R.string.update_details, MoneyFormatter.megabytes(info.sizeBytes, language)) else stringResource(R.string.common_needs_connection), + ) { content -> + KvittButton(stringResource(R.string.update_later), onLater, kind = ButtonKind.Text, contentColor = content) + KvittButton(stringResource(R.string.update_install), onUpdate, kind = ButtonKind.Tertiary, icon = KvittIcon.Download, enabled = online) + } + } + is UpdateState.Downloading -> { + val percent = (state.progress * 100).toInt().coerceIn(0, 100) + val spoken = stringResource(R.string.update_downloading_spoken, state.info.versionName) + ", " + stringResource(R.string.common_percent, percent.toString()) + val tertiary = MaterialTheme.colorScheme.tertiary + Banner( + BannerKind.Lantern, + KvittIcon.Downloading, + stringResource(R.string.update_downloading, percent), + modifier, + progress = { + // LinearWavyProgressIndicator is not in Material 3 1.4.0: stable LinearProgressIndicator (§3.1). + LinearProgressIndicator( + progress = { state.progress }, + modifier = Modifier.fillMaxWidth().semantics { contentDescription = spoken }, + color = tertiary, + trackColor = tertiary.copy(alpha = TRACK_ALPHA), + strokeCap = StrokeCap.Round, + ) + }, + ) + } + is UpdateState.Failed -> Banner(BannerKind.Error, KvittIcon.Error, stringResource(R.string.update_failed), modifier) { content -> + KvittButton(stringResource(R.string.update_later), onLater, kind = ButtonKind.Text, contentColor = content) + KvittButton(stringResource(R.string.common_retry), onRetry, kind = ButtonKind.ErrorFilled, icon = KvittIcon.Refresh) + } + } +} + +private const val TRACK_ALPHA = 0.28f + +/** Warning banner "Kräver anslutning" with a screen-specific [body] (design-spec §3.6). */ +@Composable +fun NeedsConnectionBanner(body: String, modifier: Modifier = Modifier) { + Banner(BannerKind.Warning, KvittIcon.CloudOff, stringResource(R.string.common_needs_connection), modifier, body = body) +} + +/** Supporting line under a disabled online-only control: `cloud_off` 16 + "Kräver anslutning" (design-spec §3.6). */ +@Composable +fun NeedsConnectionHint(modifier: Modifier = Modifier, text: String = stringResource(R.string.common_needs_connection)) { + Row( + modifier.padding(horizontal = KvittTheme.spacing.s4, vertical = KvittDimens.hintVertical).semantics(mergeDescendants = true) {}, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), + ) { + KvittIconImage(KvittIcon.CloudOff, null, size = KvittDimens.iconHint, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Text(text, style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Timeline.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Timeline.kt new file mode 100644 index 0000000..67e2553 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Timeline.kt @@ -0,0 +1,189 @@ +package se.hagstedt.kvitt.design.component + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +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.font.FontWeight +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.format.DateFormatter +import se.hagstedt.kvitt.design.format.KvittLanguage +import se.hagstedt.kvitt.design.format.MoneyFormatter +import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens +import se.hagstedt.kvitt.model.AuditAction +import se.hagstedt.kvitt.model.AuditSource +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.FieldChange +import se.hagstedt.kvitt.model.SplitMode +import se.hagstedt.kvitt.model.TimelineLine +import kotlin.time.Instant + +// The timeline of docs/ux/design-spec.md §3.9 (S-8, S-10; AC-12.4, AC-12.5). + +/** Field names of the audit diff (§14.4.7) in the UI language, definite form (`timeline_field_*`). */ +@Composable +@ReadOnlyComposable +private fun fieldName(field: String): String = when (field) { + "account_id" -> stringResource(R.string.timeline_field_account) + "category_id" -> stringResource(R.string.timeline_field_category) + "payer_user_id" -> stringResource(R.string.timeline_field_payer) + "amount_ore" -> stringResource(R.string.timeline_field_amount) + "description" -> stringResource(R.string.timeline_field_description) + "notes" -> stringResource(R.string.timeline_field_notes) + "expense_date", "settlement_date" -> stringResource(R.string.timeline_field_date) + "split_mode" -> stringResource(R.string.timeline_field_split_mode) + "splits" -> stringResource(R.string.timeline_field_splits) + "from_user_id" -> stringResource(R.string.timeline_field_from) + "to_user_id" -> stringResource(R.string.timeline_field_to) + "note" -> stringResource(R.string.timeline_field_note) + "allocations" -> stringResource(R.string.timeline_field_allocations) + else -> field +} + +/** + * A diff value formatted for display: öre as money, ISO dates as list dates, split modes by name, default category + * keys by their localised name, "name öre" pairs (`splits`, `allocations`, joined by "; ") with money. + */ +@Composable +@ReadOnlyComposable +private fun fieldValue(field: String, value: String): String = fieldValue(field, value, kvittLanguage()) + +@Composable +@ReadOnlyComposable +private fun fieldValue(field: String, value: String, language: KvittLanguage): String = when (field) { + "amount_ore" -> value.toLongOrNull()?.let { money(it) } ?: value + "expense_date", "settlement_date" -> if (ISO_DATE.matches(value)) listDate(value) else value + "split_mode" -> SplitMode.entries.firstOrNull { it.name.equals(value, ignoreCase = true) }?.let { mode -> + when (mode) { + SplitMode.DEFAULT -> stringResource(R.string.editor_split_mode_default, "").trim() + SplitMode.RATIO -> stringResource(R.string.editor_split_mode_ratio) + SplitMode.EXACT -> stringResource(R.string.editor_split_mode_exact) + SplitMode.FULL -> stringResource(R.string.editor_split_mode_full) + } + } ?: value + "category_id" -> DefaultCategoryKey.entries.firstOrNull { it.name.equals(value, ignoreCase = true) }?.let { stringResource(defaultCategoryName(it)) } ?: value + "splits", "allocations" -> value.split("; ").joinToString("; ") { pair -> + val name = pair.substringBeforeLast(' ') + val ore = pair.substringAfterLast(' ').toLongOrNull() + if (ore == null) pair else "$name ${MoneyFormatter.format(ore, language)}" + } + else -> value +} + +/** The sentences of one timeline entry (several changes: one line each, same time). */ +@Composable +fun timelineSentences(line: TimelineLine): List { + val actor = line.actor?.let { personName(it) } ?: "" + return when { + line.source == AuditSource.RECURRING && line.action == AuditAction.CREATE -> listOf(stringResource(R.string.timeline_recurring)) + line.source == AuditSource.IMPORT && line.action == AuditAction.CREATE -> listOf(stringResource(R.string.timeline_import)) + line.action == AuditAction.CREATE -> listOf(stringResource(R.string.timeline_created, actor)) + line.action == AuditAction.DELETE -> listOf(stringResource(R.string.timeline_deleted, actor)) + else -> line.changes.map { change -> changeSentence(actor, change) } + } +} + +@Composable +private fun changeSentence(actor: String, change: FieldChange): String { + val name = fieldName(change.field) + val old = change.oldDisplay?.takeIf { it.isNotEmpty() } + val new = change.newDisplay?.takeIf { it.isNotEmpty() } + return when { + old == null && new != null -> stringResource(R.string.timeline_changed_added, actor, name, fieldValue(change.field, new)) + new == null -> stringResource(R.string.timeline_changed_removed, actor, name) + else -> stringResource(R.string.timeline_changed, actor, name, fieldValue(change.field, old ?: ""), fieldValue(change.field, new)) + } +} + +/** + * The timeline (design-spec §3.9): header "Historik" (heading) and a card with the entries oldest first; each a 12 dp + * `secondary` dot with a 2 dp connector and the sentence with " · 16 okt. 14:02". [pendingLocal] entries show a hollow + * `pending` dot and "Din ändring väntar på synk" with `cloud_upload` as the last item. + */ +@Composable +fun Timeline(lines: List, modifier: Modifier = Modifier) { + val clock = LocalKvittClock.current + val language = kvittLanguage() + val confirmed = lines.filter { !it.pendingLocal } + val pending = lines.any { it.pendingLocal } + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { + Text(stringResource(R.string.timeline_title), Modifier.padding(horizontal = KvittTheme.spacing.s2).semantics { heading() }, style = MaterialTheme.typography.titleSmall) + KvittCard( + Modifier.fillMaxWidth(), + contentPadding = PaddingValues(start = KvittTheme.spacing.s4, end = KvittTheme.spacing.s4, top = KvittTheme.spacing.s4, bottom = KvittDimens.timelineBottom), + ) { + val items = confirmed.flatMap { line -> + val time = DateFormatter.listDateTime(Instant.parse(line.clientAt), clock.zone, clock.today, language) + timelineSentences(line).map { it + stringResource(R.string.timeline_time, time).let { t -> " $t" } } + } + val count = items.size + if (pending) 1 else 0 + items.forEachIndexed { index, text -> TimelineItem(text, last = index == count - 1, pending = false) } + if (pending) TimelineItem(stringResource(R.string.timeline_pending), last = true, pending = true) + } + } +} + +@Composable +private fun TimelineItem(text: String, last: Boolean, pending: Boolean) { + val dot = MaterialTheme.colorScheme.secondary + val connector = MaterialTheme.colorScheme.outlineVariant + val pendingColor = KvittTheme.colors.pending + val firstLineCenter = with(LocalDensity.current) { MaterialTheme.typography.bodyMedium.lineHeight.toPx() / 2 } + Row(Modifier.fillMaxWidth().height(IntrinsicSize.Min).clearAndSetSemantics { contentDescription = text }) { + Box(Modifier.width(KvittDimens.timelineGutter).fillMaxHeight()) { + Canvas(Modifier.fillMaxHeight().width(KvittDimens.timelineGutter)) { + val r = KvittDimens.timelineDot.toPx() / 2 + val cx = size.width / 2 + val cy = firstLineCenter + if (pending) { + drawCircle(pendingColor, r - KvittDimens.timelineConnector.toPx() / 2, Offset(cx, cy), style = Stroke(KvittDimens.timelineConnector.toPx())) + } else { + drawCircle(dot, r, Offset(cx, cy)) + } + if (!last) { + val w = KvittDimens.timelineConnector.toPx() + drawLine(connector, Offset(cx, cy + r + w * 2), Offset(cx, size.height - w), strokeWidth = w) + } + } + } + Row( + Modifier.weight(1f).padding(start = KvittTheme.spacing.s3, bottom = if (last) KvittTheme.spacing.s1 else KvittDimens.timelineItemGap), + horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), + verticalAlignment = Alignment.Top, + ) { + if (pending) SyncPendingMarker(Modifier.padding(top = KvittDimens.divider * 2), size = KvittDimens.iconHint, standalone = false) + Text( + text, + style = if (pending) MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold) else MaterialTheme.typography.bodyMedium, + color = if (pending) pendingColor else MaterialTheme.colorScheme.onSurface, + ) + } + } +} + + +private val ISO_DATE = Regex("\\d{4}-\\d{2}-\\d{2}") diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/AmountInput.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/AmountInput.kt new file mode 100644 index 0000000..b701e19 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/AmountInput.kt @@ -0,0 +1,90 @@ +package se.hagstedt.kvitt.design.format + +/** + * The keypad's amount text and its input rules (SPEC §9.6, design-spec §3.7 `AmountKeypad`), pure Kotlin. + * + * [raw] holds digits and at most one `.` as the decimal point, independent of the language; [display] shows it with + * the language's decimal separator and U+00A0 thousands grouping ("1 060,5"). Parsing to öre is string-exact. + */ +data class AmountInput(val raw: String = "") { + val isEmpty: Boolean get() = raw.isEmpty() + + /** Öre value, or null when empty. "412" -> 41 200, "0,5" -> 50, "12," -> 1 200. */ + val ore: Long? + get() { + if (raw.isEmpty()) return null + val whole = raw.substringBefore('.').ifEmpty { "0" } + val fraction = raw.substringAfter('.', "").padEnd(2, '0') + return whole.toLong() * 100 + fraction.toLong() + } + + /** The typed text with grouping: "1 060", "1 060,5", "0,". */ + fun display(language: KvittLanguage): String { + if (raw.isEmpty()) return "" + val whole = raw.substringBefore('.') + val separator = MoneyFormatter.groupingSeparator(language) + val grouped = buildString { + whole.forEachIndexed { index, c -> + if (index > 0 && (whole.length - index) % 3 == 0) append(separator) + append(c) + } + } + return if ('.' in raw) grouped + MoneyFormatter.decimalSeparator(language) + raw.substringAfter('.') else grouped + } + + /** + * Applies one key. Returns null when the key is rejected (the keypad then gives a reject haptic): a third decimal, + * a second decimal separator, or a value above [maxOre]. + */ + fun press(key: AmountKey, maxOre: Long = MAX_AMOUNT_ORE): AmountInput? { + val next = when (key) { + is AmountKey.Digit -> { + if ('.' in raw && raw.substringAfter('.').length >= 2) return null + if (raw == "0") key.digit.toString() else raw + key.digit + } + AmountKey.Decimal -> when { + '.' in raw -> return null + raw.isEmpty() -> "0." + else -> "$raw." + } + AmountKey.Delete -> raw.dropLast(1) + AmountKey.Clear -> "" + } + val candidate = AmountInput(next) + val value = candidate.ore + return if (value != null && value > maxOre) null else candidate + } + + companion object { + /** 10 000 000,00 kr (SPEC §9.6). */ + const val MAX_AMOUNT_ORE = 1_000_000_000L + + /** Percent fields of the ratio editor: at most 100 (§3.7). */ + const val MAX_PERCENT_HUNDREDTHS = 10_000L + + /** Restores the text of a stored amount for editing: 124 760 -> "1247.6", 41 200 -> "412". */ + fun of(ore: Long): AmountInput { + val whole = ore / 100 + val cents = ore % 100 + val raw = when { + cents == 0L -> whole.toString() + cents % 10 == 0L -> "$whole.${cents / 10}" + else -> "$whole.${cents.toString().padStart(2, '0')}" + } + return AmountInput(raw) + } + } +} + +/** A key of the amount keypad. */ +sealed interface AmountKey { + data class Digit(val digit: Int) : AmountKey { + init { + require(digit in 0..9) { "digit $digit" } + } + } + + data object Decimal : AmountKey + data object Delete : AmountKey + data object Clear : AmountKey +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/DateFormatter.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/DateFormatter.kt new file mode 100644 index 0000000..6ab684e --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/DateFormatter.kt @@ -0,0 +1,179 @@ +package se.hagstedt.kvitt.design.format + +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.Month +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +/** "Last synced" relative time of SPEC §9.8; the caller turns it into words with the `time_*` strings. */ +sealed interface RelativeTime { + /** Less than a minute ago: "nyss" / "just now". */ + data object JustNow : RelativeTime + + /** Less than 60 minutes: "för N min sedan" / "N min ago". */ + data class MinutesAgo(val minutes: Int) : RelativeTime + + /** Less than 24 hours: "för N h sedan" / "N h ago". */ + data class HoursAgo(val hours: Int) : RelativeTime + + /** Otherwise the list date and time: "16 okt. 14:02". */ + data class At(val text: String) : RelativeTime +} + +/** + * Dates and times per SPEC §9.8, pure Kotlin on kotlinx-datetime. Month and weekday names are this formatter's own + * tables (Swedish and English), so the result never depends on the platform's locale data. + * + * Dates arrive as ISO strings (`YYYY-MM-DD`), periods as `YYYY-MM`, week buckets as `YYYY-Www`, instants as RFC 3339; + * times are shown in [zone] (the phone's zone; Europe/Stockholm for both users). + */ +object DateFormatter { + private val SV_MONTHS = listOf("januari", "februari", "mars", "april", "maj", "juni", "juli", "augusti", "september", "oktober", "november", "december") + private val SV_MONTHS_SHORT = listOf("jan.", "feb.", "mars", "apr.", "maj", "juni", "juli", "aug.", "sep.", "okt.", "nov.", "dec.") + private val SV_MONTHS_AXIS = listOf("jan", "feb", "mar", "apr", "maj", "jun", "jul", "aug", "sep", "okt", "nov", "dec") + private val EN_MONTHS = listOf("January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December") + private val EN_MONTHS_SHORT = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") + private val SV_WEEKDAYS = listOf("måndag", "tisdag", "onsdag", "torsdag", "fredag", "lördag", "söndag") + private val SV_WEEKDAYS_SHORT = listOf("mån", "tis", "ons", "tors", "fre", "lör", "sön") + private val EN_WEEKDAYS = listOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") + private val EN_WEEKDAYS_SHORT = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + private const val EN_DASH = '–' + + fun parseDate(iso: String): LocalDate = LocalDate.parse(iso) + + /** Dates in lists: `3 okt.` / `3 Oct`; with the year when it is not the year of [today]: `3 okt. 2025`. */ + fun listDate(date: LocalDate, today: LocalDate, language: KvittLanguage): String { + val base = "${date.day} ${monthShort(date, language)}" + return if (date.year == today.year) base else "$base ${date.year}" + } + + fun listDate(iso: String, today: String, language: KvittLanguage): String = listDate(parseDate(iso), parseDate(today), language) + + /** Day and full month for TalkBack sentences: `16 oktober` / `16 October`; with the year when not [today]'s. */ + fun spokenDate(date: LocalDate, today: LocalDate, language: KvittLanguage): String { + val base = "${date.day} ${monthName(date.month.index + 1, language)}" + return if (date.year == today.year) base else "$base ${date.year}" + } + + /** Detail views: `lördag 3 oktober 2026` / `Saturday, 3 October 2026`. */ + fun detailDate(date: LocalDate, language: KvittLanguage): String = when (language) { + KvittLanguage.SWEDISH -> "${SV_WEEKDAYS[date.dayOfWeek.index]} ${date.day} ${SV_MONTHS[date.month.index]} ${date.year}" + KvittLanguage.ENGLISH -> "${EN_WEEKDAYS[date.dayOfWeek.index]}, ${date.day} ${EN_MONTHS[date.month.index]} ${date.year}" + } + + fun detailDate(iso: String, language: KvittLanguage): String = detailDate(parseDate(iso), language) + + /** 24-hour time in both languages: `14:02`. */ + fun time(instant: Instant, zone: TimeZone): String = time(instant.toLocalDateTime(zone)) + + fun time(local: LocalDateTime): String = "${local.hour.toString().padStart(2, '0')}:${local.minute.toString().padStart(2, '0')}" + + /** List date and time, as in the timeline: `16 okt. 14:02`. */ + fun listDateTime(instant: Instant, zone: TimeZone, today: LocalDate, language: KvittLanguage): String { + val local = instant.toLocalDateTime(zone) + return "${listDate(local.date, today, language)} ${time(local)}" + } + + fun listDateTime(rfc3339: String, zone: TimeZone, today: String, language: KvittLanguage): String = + listDateTime(Instant.parse(rfc3339), zone, parseDate(today), language) + + /** Short weekday, list date and time, for expiry lines: `mån 19 okt. 21:40` / `Mon 19 Oct 21:40`. */ + fun weekdayDateTime(instant: Instant, zone: TimeZone, today: LocalDate, language: KvittLanguage): String { + val local = instant.toLocalDateTime(zone) + val weekday = if (language == KvittLanguage.SWEDISH) SV_WEEKDAYS_SHORT[local.dayOfWeek.index] else EN_WEEKDAYS_SHORT[local.dayOfWeek.index] + return "$weekday ${listDateTime(instant, zone, today, language)}" + } + + /** "Last synced" (SPEC §9.8): just now (< 1 min), N min (< 60 min), N h (< 24 h), otherwise list date and time. */ + fun relative(then: Instant, now: Instant, zone: TimeZone, language: KvittLanguage): RelativeTime { + val elapsed = now - then + val minutes = elapsed.inWholeMinutes + return when { + minutes < 1 -> RelativeTime.JustNow + minutes < 60 -> RelativeTime.MinutesAgo(minutes.toInt()) + elapsed.inWholeHours < 24 -> RelativeTime.HoursAgo(elapsed.inWholeHours.toInt()) + else -> RelativeTime.At(listDateTime(then, zone, now.toLocalDateTime(zone).date, language)) + } + } + + /** + * Month header of a ledger group from a `YYYY-MM` period: `Oktober` / `October`, with the year when it is not the + * year of [today]: `September 2025` (design-spec §3.5). + */ + fun monthHeader(period: String, today: LocalDate, language: KvittLanguage): String { + val (year, month) = parsePeriod(period) + val name = monthName(month, language).replaceFirstChar { it.uppercaseChar() } + return if (year == today.year) name else "$name $year" + } + + /** Month and year in running text: `oktober 2026` / `October 2026`. */ + fun monthYear(period: String, language: KvittLanguage): String { + val (year, month) = parsePeriod(period) + return "${monthName(month, language)} $year" + } + + /** Short month for chart x labels and range captions: `maj`, `okt` / `May`, `Oct`. */ + fun axisMonth(month: Int, language: KvittLanguage): String = + if (language == KvittLanguage.SWEDISH) SV_MONTHS_AXIS[month - 1] else EN_MONTHS_SHORT[month - 1] + + /** A month range caption: `maj–okt 2026`; across years `nov 2025–feb 2026`. */ + fun monthRange(start: LocalDate, end: LocalDate, language: KvittLanguage): String { + val first = axisMonth(start.month.index + 1, language) + val last = axisMonth(end.month.index + 1, language) + return when { + start.year == end.year && start.month == end.month -> "$last ${end.year}" + start.year == end.year -> "$first$EN_DASH$last ${end.year}" + else -> "$first ${start.year}$EN_DASH$last ${end.year}" + } + } + + /** A day range caption: `1–30 nov.`, `24 aug.–17 okt.` / `1–30 Nov`, `24 Aug–17 Oct` (current year omitted). */ + fun dayRange(start: LocalDate, end: LocalDate, today: LocalDate, language: KvittLanguage): String = + if (start.year == end.year && start.month == end.month) { + "${start.day}$EN_DASH${listDate(end, today, language)}" + } else { + "${listDate(start, today, language)}$EN_DASH${listDate(end, today, language)}" + } + + /** + * Bucket key of the analytics series (`YYYY-MM` or `YYYY-Www`, §14.5.4) as an x label: the short month (`okt`) or + * the week number (`41`). + */ + fun bucketLabel(key: String, language: KvittLanguage): String = + weekOfKey(key)?.toString() ?: axisMonth(parsePeriod(key).second, language) + + /** The ISO week number of a week key `2026-W41`, or null for a month key. */ + fun weekOfKey(key: String): Int? = key.substringAfter("-W", "").takeIf { it.isNotEmpty() }?.toIntOrNull() + + /** ISO 8601 week number of [date] (weeks start on Monday; week 1 holds the year's first Thursday). */ + fun isoWeek(date: LocalDate): Int { + val thursday = LocalDate.fromEpochDays(date.toEpochDays() - date.dayOfWeek.index + 3) + val firstOfYear = LocalDate(thursday.year, 1, 1) + return ((thursday.toEpochDays() - firstOfYear.toEpochDays()) / 7 + 1).toInt() + } + + /** ISO week key of [date]: `2026-W53` for 1 January 2027. */ + fun isoWeekKey(date: LocalDate): String { + val thursday = LocalDate.fromEpochDays(date.toEpochDays() - date.dayOfWeek.index + 3) + return "${thursday.year}-W${isoWeek(date).toString().padStart(2, '0')}" + } + + private fun monthShort(date: LocalDate, language: KvittLanguage): String = + if (language == KvittLanguage.SWEDISH) SV_MONTHS_SHORT[date.month.index] else EN_MONTHS_SHORT[date.month.index] + + private fun monthName(month: Int, language: KvittLanguage): String = + if (language == KvittLanguage.SWEDISH) SV_MONTHS[month - 1] else EN_MONTHS[month - 1] + + private fun parsePeriod(period: String): Pair { + val year = period.substringBefore('-').toInt() + val month = period.substringAfter('-').take(2).toInt() + require(month in 1..12) { "period $period" } + return year to month + } + + private val DayOfWeek.index: Int get() = ordinal + private val Month.index: Int get() = ordinal +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/MoneyFormatter.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/MoneyFormatter.kt new file mode 100644 index 0000000..ec9edfe --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/format/MoneyFormatter.kt @@ -0,0 +1,92 @@ +package se.hagstedt.kvitt.design.format + +/** + * The two formatting languages of SPEC §9.8 and FR-19: Swedish, and every other locale (English forms). + * [of] maps a BCP 47 language tag or ISO language code. + */ +enum class KvittLanguage { + SWEDISH, ENGLISH; + + companion object { + fun of(language: String): KvittLanguage = if (language.substringBefore('-').substringBefore('_').equals("sv", ignoreCase = true)) SWEDISH else ENGLISH + } +} + +/** An amount split into its number and unit, for styles that set the unit apart (hero, editor). */ +data class MoneyParts(val number: String, val unit: String, val unitFirst: Boolean) + +/** + * Money per SPEC §9.8, pure Kotlin. Swedish `1 234,50 kr` (U+00A0 thousands separator and before `kr`, comma decimal), + * other locales `SEK 1,234.50` (U+00A0 after `SEK`). Always two decimals, never a sign: direction is said in words. + */ +object MoneyFormatter { + const val NBSP = ' ' + private const val SWEDISH_UNIT = "kr" + private const val OTHER_UNIT = "SEK" + + /** `1 234,50 kr` / `SEK 1,234.50`. */ + fun format(ore: Long, language: KvittLanguage): String { + val parts = parts(ore, language) + return if (parts.unitFirst) "${parts.unit}$NBSP${parts.number}" else "${parts.number}$NBSP${parts.unit}" + } + + fun parts(ore: Long, language: KvittLanguage): MoneyParts = MoneyParts( + number = number(ore, language), + unit = unit(language), + unitFirst = language == KvittLanguage.ENGLISH, + ) + + fun unit(language: KvittLanguage): String = if (language == KvittLanguage.SWEDISH) SWEDISH_UNIT else OTHER_UNIT + + /** The number without unit or sign: `1 234,50` / `1,234.50`. TalkBack reads it inside `common_amount_spoken`. */ + fun number(ore: Long, language: KvittLanguage): String { + val kronor = ore / 100 + val cents = ore % 100 + val whole = group(magnitude(kronor), language) + val fraction = magnitude(cents).padStart(2, '0') + return whole + decimalSeparator(language) + fraction + } + + /** Whole kronor with grouping, for chart axis labels: `15 000` / `15,000`. */ + fun wholeKronor(kronor: Long, language: KvittLanguage): String = group(magnitude(kronor), language) + + fun decimalSeparator(language: KvittLanguage): Char = if (language == KvittLanguage.SWEDISH) ',' else '.' + + fun groupingSeparator(language: KvittLanguage): Char = if (language == KvittLanguage.SWEDISH) NBSP else ',' + + /** + * Percent of [part] in [total], rounded half-up to one decimal (AC-14.10), as a number: `34,8` / `34.8`. + * The `%` sign comes from the string `common_percent`. A zero total gives `0,0`. + */ + fun percent(part: Long, total: Long, language: KvittLanguage): String { + val tenths = if (total <= 0L) 0L else (part * 2000L + total) / (2L * total) + return "${tenths / 10}${decimalSeparator(language)}${tenths % 10}" + } + + /** Ratio percent of basis points without decimals when whole: 6000 -> `60`, 3333 -> `33,33` (§9.6 two decimals). */ + fun basisPoints(bp: Int, language: KvittLanguage): String { + val whole = bp / 100 + val rest = bp % 100 + if (rest == 0) return whole.toString() + val decimals = rest.toString().padStart(2, '0').trimEnd('0') + return "$whole${decimalSeparator(language)}$decimals" + } + + /** File size in megabytes (10^6 bytes) with one decimal, half-up: 8 400 000 -> `8,4 MB` / `8.4 MB`. */ + fun megabytes(bytes: Long, language: KvittLanguage): String { + val tenths = (bytes * 2L + 100_000L) / 200_000L + return "${tenths / 10}${decimalSeparator(language)}${tenths % 10}${NBSP}MB" + } + + private fun magnitude(value: Long): String = value.toString().removePrefix("-") + + private fun group(digits: String, language: KvittLanguage): String { + val separator = groupingSeparator(language) + val builder = StringBuilder() + digits.forEachIndexed { index, c -> + if (index > 0 && (digits.length - index) % 3 == 0) builder.append(separator) + builder.append(c) + } + return builder.toString() + } +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/icon/KvittIcons.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/icon/KvittIcons.kt new file mode 100644 index 0000000..5b7174f --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/icon/KvittIcons.kt @@ -0,0 +1,205 @@ +package se.hagstedt.kvitt.design.icon + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import se.hagstedt.kvitt.core.design.R + +// Icons of docs/ux/design-spec.md §2.12: Material Symbols Rounded, optical size 24, grade 0, shipped as vector drawables +// generated from Google's SVGs (Apache License 2.0). Outline = FILL 0, weight 500; filled = FILL 1, weight 600. +// `card_giftcard` is drawn with the Symbols glyph `redeem`, which the Symbols set publishes under that name. + +/** The UI icons of design-spec §2.12, each with its outline and filled variant. */ +enum class KvittIcon(@param:DrawableRes val outline: Int, @param:DrawableRes val filled: Int) { + Add(R.drawable.ic_add, R.drawable.ic_add_filled), + AddAPhoto(R.drawable.ic_add_a_photo, R.drawable.ic_add_a_photo_filled), + AddLink(R.drawable.ic_add_link, R.drawable.ic_add_link_filled), + Apps(R.drawable.ic_apps, R.drawable.ic_apps_filled), + ArrowBack(R.drawable.ic_arrow_back, R.drawable.ic_arrow_back_filled), + ArrowForward(R.drawable.ic_arrow_forward, R.drawable.ic_arrow_forward_filled), + Backspace(R.drawable.ic_backspace, R.drawable.ic_backspace_filled), + BarChart(R.drawable.ic_bar_chart, R.drawable.ic_bar_chart_filled), + Block(R.drawable.ic_block, R.drawable.ic_block_filled), + Calculate(R.drawable.ic_calculate, R.drawable.ic_calculate_filled), + CallSplit(R.drawable.ic_call_split, R.drawable.ic_call_split_filled), + Category(R.drawable.ic_category, R.drawable.ic_category_filled), + Check(R.drawable.ic_check, R.drawable.ic_check_filled), + CheckCircle(R.drawable.ic_check_circle, R.drawable.ic_check_circle_filled), + ChevronRight(R.drawable.ic_chevron_right, R.drawable.ic_chevron_right_filled), + Close(R.drawable.ic_close, R.drawable.ic_close_filled), + CloudDone(R.drawable.ic_cloud_done, R.drawable.ic_cloud_done_filled), + CloudOff(R.drawable.ic_cloud_off, R.drawable.ic_cloud_off_filled), + CloudUpload(R.drawable.ic_cloud_upload, R.drawable.ic_cloud_upload_filled), + ContentCopy(R.drawable.ic_content_copy, R.drawable.ic_content_copy_filled), + ContentPaste(R.drawable.ic_content_paste, R.drawable.ic_content_paste_filled), + DateRange(R.drawable.ic_date_range, R.drawable.ic_date_range_filled), + Delete(R.drawable.ic_delete, R.drawable.ic_delete_filled), + Description(R.drawable.ic_description, R.drawable.ic_description_filled), + Devices(R.drawable.ic_devices, R.drawable.ic_devices_filled), + Dns(R.drawable.ic_dns, R.drawable.ic_dns_filled), + Download(R.drawable.ic_download, R.drawable.ic_download_filled), + Downloading(R.drawable.ic_downloading, R.drawable.ic_downloading_filled), + DragIndicator(R.drawable.ic_drag_indicator, R.drawable.ic_drag_indicator_filled), + Edit(R.drawable.ic_edit, R.drawable.ic_edit_filled), + Error(R.drawable.ic_error, R.drawable.ic_error_filled), + Event(R.drawable.ic_event, R.drawable.ic_event_filled), + EventRepeat(R.drawable.ic_event_repeat, R.drawable.ic_event_repeat_filled), + ExpandLess(R.drawable.ic_expand_less, R.drawable.ic_expand_less_filled), + ExpandMore(R.drawable.ic_expand_more, R.drawable.ic_expand_more_filled), + Handshake(R.drawable.ic_handshake, R.drawable.ic_handshake_filled), + Home(R.drawable.ic_home, R.drawable.ic_home_filled), + Info(R.drawable.ic_info, R.drawable.ic_info_filled), + InstallMobile(R.drawable.ic_install_mobile, R.drawable.ic_install_mobile_filled), + Inventory2(R.drawable.ic_inventory_2, R.drawable.ic_inventory_2_filled), + Key(R.drawable.ic_key, R.drawable.ic_key_filled), + Link(R.drawable.ic_link, R.drawable.ic_link_filled), + Lock(R.drawable.ic_lock, R.drawable.ic_lock_filled), + Memory(R.drawable.ic_memory, R.drawable.ic_memory_filled), + MoreVert(R.drawable.ic_more_vert, R.drawable.ic_more_vert_filled), + Notes(R.drawable.ic_notes, R.drawable.ic_notes_filled), + OpenInNew(R.drawable.ic_open_in_new, R.drawable.ic_open_in_new_filled), + Pause(R.drawable.ic_pause, R.drawable.ic_pause_filled), + Person(R.drawable.ic_person, R.drawable.ic_person_filled), + PersonAdd(R.drawable.ic_person_add, R.drawable.ic_person_add_filled), + PhonelinkErase(R.drawable.ic_phonelink_erase, R.drawable.ic_phonelink_erase_filled), + PhotoLibrary(R.drawable.ic_photo_library, R.drawable.ic_photo_library_filled), + ReceiptLong(R.drawable.ic_receipt_long, R.drawable.ic_receipt_long_filled), + Refresh(R.drawable.ic_refresh, R.drawable.ic_refresh_filled), + RestartAlt(R.drawable.ic_restart_alt, R.drawable.ic_restart_alt_filled), + Schedule(R.drawable.ic_schedule, R.drawable.ic_schedule_filled), + Settings(R.drawable.ic_settings, R.drawable.ic_settings_filled), + Share(R.drawable.ic_share, R.drawable.ic_share_filled), + Smartphone(R.drawable.ic_smartphone, R.drawable.ic_smartphone_filled), + SwapHoriz(R.drawable.ic_swap_horiz, R.drawable.ic_swap_horiz_filled), + Sync(R.drawable.ic_sync, R.drawable.ic_sync_filled), + SyncProblem(R.drawable.ic_sync_problem, R.drawable.ic_sync_problem_filled), + SystemUpdate(R.drawable.ic_system_update, R.drawable.ic_system_update_filled), + Unarchive(R.drawable.ic_unarchive, R.drawable.ic_unarchive_filled), + Warning(R.drawable.ic_warning, R.drawable.ic_warning_filled), +} + +/** Check badge of the selected category tile: `check`, FILL 1, weight 700 (design-spec §3.7). */ +@DrawableRes +val CheckBadgeIcon: Int = R.drawable.ic_check_badge + +/** Category icon tokens (SPEC §9.7, `CategoryIcons.ALL`): the filled glyph and the TalkBack name `icon_`. */ +object CategoryIconRes { + private val drawables: Map = mapOf( + "shopping_cart" to R.drawable.ic_shopping_cart_filled, + "restaurant" to R.drawable.ic_restaurant_filled, + "local_cafe" to R.drawable.ic_local_cafe_filled, + "home" to R.drawable.ic_home_filled, + "bolt" to R.drawable.ic_bolt_filled, + "water_drop" to R.drawable.ic_water_drop_filled, + "wifi" to R.drawable.ic_wifi_filled, + "directions_car" to R.drawable.ic_directions_car_filled, + "train" to R.drawable.ic_train_filled, + "local_gas_station" to R.drawable.ic_local_gas_station_filled, + "flight" to R.drawable.ic_flight_filled, + "hotel" to R.drawable.ic_hotel_filled, + "luggage" to R.drawable.ic_luggage_filled, + "movie" to R.drawable.ic_movie_filled, + "music_note" to R.drawable.ic_music_note_filled, + "sports_esports" to R.drawable.ic_sports_esports_filled, + "fitness_center" to R.drawable.ic_fitness_center_filled, + "medical_services" to R.drawable.ic_medical_services_filled, + "medication" to R.drawable.ic_medication_filled, + "shopping_bag" to R.drawable.ic_shopping_bag_filled, + "checkroom" to R.drawable.ic_checkroom_filled, + "pets" to R.drawable.ic_pets_filled, + "child_care" to R.drawable.ic_child_care_filled, + "school" to R.drawable.ic_school_filled, + "card_giftcard" to R.drawable.ic_card_giftcard_filled, + "celebration" to R.drawable.ic_celebration_filled, + "build" to R.drawable.ic_build_filled, + "chair" to R.drawable.ic_chair_filled, + "subscriptions" to R.drawable.ic_subscriptions_filled, + "category" to R.drawable.ic_category_filled, + "key" to R.drawable.ic_key_filled, + "shield" to R.drawable.ic_shield_filled, + "account_balance" to R.drawable.ic_account_balance_filled, + ) + private val outlines: Map = mapOf( + "shopping_cart" to R.drawable.ic_shopping_cart, + "restaurant" to R.drawable.ic_restaurant, + "local_cafe" to R.drawable.ic_local_cafe, + "home" to R.drawable.ic_home, + "bolt" to R.drawable.ic_bolt, + "water_drop" to R.drawable.ic_water_drop, + "wifi" to R.drawable.ic_wifi, + "directions_car" to R.drawable.ic_directions_car, + "train" to R.drawable.ic_train, + "local_gas_station" to R.drawable.ic_local_gas_station, + "flight" to R.drawable.ic_flight, + "hotel" to R.drawable.ic_hotel, + "luggage" to R.drawable.ic_luggage, + "movie" to R.drawable.ic_movie, + "music_note" to R.drawable.ic_music_note, + "sports_esports" to R.drawable.ic_sports_esports, + "fitness_center" to R.drawable.ic_fitness_center, + "medical_services" to R.drawable.ic_medical_services, + "medication" to R.drawable.ic_medication, + "shopping_bag" to R.drawable.ic_shopping_bag, + "checkroom" to R.drawable.ic_checkroom, + "pets" to R.drawable.ic_pets, + "child_care" to R.drawable.ic_child_care, + "school" to R.drawable.ic_school, + "card_giftcard" to R.drawable.ic_card_giftcard, + "celebration" to R.drawable.ic_celebration, + "build" to R.drawable.ic_build, + "chair" to R.drawable.ic_chair, + "subscriptions" to R.drawable.ic_subscriptions, + "category" to R.drawable.ic_category, + "key" to R.drawable.ic_key, + "shield" to R.drawable.ic_shield, + "account_balance" to R.drawable.ic_account_balance, + ) + private val names: Map = mapOf( + "shopping_cart" to R.string.icon_shopping_cart, + "restaurant" to R.string.icon_restaurant, + "local_cafe" to R.string.icon_local_cafe, + "home" to R.string.icon_home, + "bolt" to R.string.icon_bolt, + "water_drop" to R.string.icon_water_drop, + "wifi" to R.string.icon_wifi, + "directions_car" to R.string.icon_directions_car, + "train" to R.string.icon_train, + "local_gas_station" to R.string.icon_local_gas_station, + "flight" to R.string.icon_flight, + "hotel" to R.string.icon_hotel, + "luggage" to R.string.icon_luggage, + "movie" to R.string.icon_movie, + "music_note" to R.string.icon_music_note, + "sports_esports" to R.string.icon_sports_esports, + "fitness_center" to R.string.icon_fitness_center, + "medical_services" to R.string.icon_medical_services, + "medication" to R.string.icon_medication, + "shopping_bag" to R.string.icon_shopping_bag, + "checkroom" to R.string.icon_checkroom, + "pets" to R.string.icon_pets, + "child_care" to R.string.icon_child_care, + "school" to R.string.icon_school, + "card_giftcard" to R.string.icon_card_giftcard, + "celebration" to R.string.icon_celebration, + "build" to R.string.icon_build, + "chair" to R.string.icon_chair, + "subscriptions" to R.string.icon_subscriptions, + "category" to R.string.icon_category, + "key" to R.string.icon_key, + "shield" to R.string.icon_shield, + "account_balance" to R.string.icon_account_balance, + ) + + /** Filled drawable of [token]; an unknown token (never produced by the server, §9.7) draws `category`. */ + @DrawableRes + fun drawable(token: String): Int = drawables[token] ?: R.drawable.ic_category_filled + + /** The outline glyph (FILL 0, weight 500) of [token], for unselected icon-picker cells (design-spec §3.4). */ + @DrawableRes + fun outline(token: String): Int = outlines[token] ?: R.drawable.ic_category + + @StringRes + fun name(token: String): Int = names[token] ?: R.string.icon_category + + /** True for every token of SPEC §9.7. */ + fun isKnown(token: String): Boolean = token in drawables +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittColors.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittColors.kt new file mode 100644 index 0000000..24e569f --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittColors.kt @@ -0,0 +1,265 @@ +package se.hagstedt.kvitt.design.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import se.hagstedt.kvitt.model.ColorToken + +// The colour tokens of docs/ux/design-spec.md §2.1 to §2.5 ("Kalas", muted; SPEC §9.7 v0.9, D-047, D-051, D-054). +// This file is the only place in the app where colour values are written down. + +/** Material 3 colour roles, light (design-spec §2.1). */ +internal val KvittLightColorScheme: ColorScheme = lightColorScheme( + primary = Color(0xFF9C5238), + onPrimary = Color(0xFFFFFFFF), + primaryContainer = Color(0xFFF2DED4), + onPrimaryContainer = Color(0xFF3A1609), + secondary = Color(0xFF5A6D52), + onSecondary = Color(0xFFFFFFFF), + secondaryContainer = Color(0xFFDDE6D3), + onSecondaryContainer = Color(0xFF172410), + tertiary = Color(0xFF76612F), + onTertiary = Color(0xFFFFFFFF), + tertiaryContainer = Color(0xFFEEE3C6), + onTertiaryContainer = Color(0xFF261C04), + error = Color(0xFFB3261E), + onError = Color(0xFFFFFFFF), + errorContainer = Color(0xFFF9DEDA), + onErrorContainer = Color(0xFF410E0B), + background = Color(0xFFFAF7F2), + onBackground = Color(0xFF22201D), + surface = Color(0xFFFAF7F2), + onSurface = Color(0xFF22201D), + surfaceVariant = Color(0xFFE8E2D9), + onSurfaceVariant = Color(0xFF4E4842), + surfaceTint = Color(0xFF9C5238), + surfaceContainerLowest = Color(0xFFFFFFFF), + surfaceContainerLow = Color(0xFFF5F1EB), + surfaceContainer = Color(0xFFF0EBE4), + surfaceContainerHigh = Color(0xFFEAE5DD), + surfaceContainerHighest = Color(0xFFE4DED6), + surfaceDim = Color(0xFFDCD6CE), + surfaceBright = Color(0xFFFAF7F2), + inverseSurface = Color(0xFF36322E), + inverseOnSurface = Color(0xFFF6F1EA), + inversePrimary = Color(0xFFE4B39F), + outline = Color(0xFF7E766E), + outlineVariant = Color(0xFFD2CAC1), + scrim = Color(0x61000000), +) + +/** Material 3 colour roles, dark (design-spec §2.1). */ +internal val KvittDarkColorScheme: ColorScheme = darkColorScheme( + primary = Color(0xFFE6B5A2), + onPrimary = Color(0xFF4A1F10), + primaryContainer = Color(0xFF6A3826), + onPrimaryContainer = Color(0xFFF6DCD0), + secondary = Color(0xFFBCCBB1), + onSecondary = Color(0xFF27341F), + secondaryContainer = Color(0xFF3D4A35), + onSecondaryContainer = Color(0xFFD9E6CD), + tertiary = Color(0xFFDCC690), + onTertiary = Color(0xFF3A2E0B), + tertiaryContainer = Color(0xFF55432E), + onTertiaryContainer = Color(0xFFF1E3C4), + error = Color(0xFFF2B8B1), + onError = Color(0xFF5F1410), + errorContainer = Color(0xFF73302A), + onErrorContainer = Color(0xFFF9DEDA), + background = Color(0xFF1B1917), + onBackground = Color(0xFFECE6DF), + surface = Color(0xFF1B1917), + onSurface = Color(0xFFECE6DF), + surfaceVariant = Color(0xFF4A4540), + onSurfaceVariant = Color(0xFFCDC5BC), + surfaceTint = Color(0xFFE6B5A2), + surfaceContainerLowest = Color(0xFF151311), + surfaceContainerLow = Color(0xFF221F1D), + surfaceContainer = Color(0xFF272422), + surfaceContainerHigh = Color(0xFF312E2B), + surfaceContainerHighest = Color(0xFF3C3835), + surfaceDim = Color(0xFF1B1917), + surfaceBright = Color(0xFF423E3A), + inverseSurface = Color(0xFFECE6DF), + inverseOnSurface = Color(0xFF312E2B), + inversePrimary = Color(0xFF9C5238), + outline = Color(0xFF978E86), + outlineVariant = Color(0xFF4A4540), + scrim = Color(0x61000000), +) + +/** A category colour with its container (design-spec §2.3). */ +@Immutable +data class CategoryColor(val solid: Color, val container: Color) + +/** + * Kvitt's semantic roles, category, chart and avatar colours (design-spec §2.2 to §2.5). Read through + * `KvittTheme.colors`; the light and dark instances are [KvittLightColors] and [KvittDarkColors]. + */ +@Immutable +data class KvittColors( + val owed: Color, + val onOwed: Color, + val owedContainer: Color, + val onOwedContainer: Color, + val owing: Color, + val onOwing: Color, + val owingContainer: Color, + val onOwingContainer: Color, + val settled: Color, + val settledContainer: Color, + val onSettledContainer: Color, + val warning: Color, + val onWarning: Color, + val warningContainer: Color, + val onWarningContainer: Color, + val pending: Color, + val card: Color, + val onCat: Color, + val shadow: Color, + val settlementRow: Color, + val settlementHero: Color, + val syncPillPending: Color, + val pendingBadge: Color, + val chartGrid: Color, + val chartAxisLabel: Color, + private val avatars: List>, + private val categories: Map, + private val chartCats: List, + private val accountHeaders: List, + /** Sequential chart tokens chart-seq-1 to chart-seq-5 (reserved, no v1 screen uses them). */ + val chartSeq: List, +) { + /** Solid colour of a category token (tiles, chart segments, swatches; never text). */ + fun category(token: ColorToken): Color = categoryColor(token).solid + + /** The token mixed over `surface` (18 % light, 24 % dark): unselected tiles, hero and header cards. */ + fun categoryContainer(token: ColorToken): Color = categoryColor(token).container + + fun categoryColor(token: ColorToken): CategoryColor = + categories[token] ?: error("KvittColors has no value for $token") // exhaustive by construction, see tests + + /** chart-cat-n of an account at 0-based [index] in AccountRepository.all() order (design-spec §2.4). */ + fun account(index: Int): Color = chartCats[index.mod(chartCats.size)] + + /** account-header-n tint of the account at 0-based [index] (design-spec §2.2). */ + fun accountHeader(index: Int): Color = accountHeaders[index.mod(accountHeaders.size)] + + /** Initials background of avatar [slot] (1 or 2, design-spec §2.5). */ + fun avatar(slot: Int): Color = avatars[slotIndex(slot)].first + + fun onAvatar(slot: Int): Color = avatars[slotIndex(slot)].second + + private fun slotIndex(slot: Int): Int = (slot - 1).coerceIn(0, avatars.lastIndex) +} + +private fun categories(vararg values: Pair): Map = mapOf(*values) + +private fun cat(solid: Long, container: Long) = CategoryColor(Color(solid), Color(container)) + +val KvittLightColors = KvittColors( + owed = Color(0xFF3D6A4C), + onOwed = Color(0xFFFFFFFF), + owedContainer = Color(0xFFD6E7D9), + onOwedContainer = Color(0xFF0F2416), + owing = Color(0xFF78497A), + onOwing = Color(0xFFFFFFFF), + owingContainer = Color(0xFFEEDDEC), + onOwingContainer = Color(0xFF2D1430), + settled = Color(0xFF6A5A2B), + settledContainer = Color(0xFFEEE3C6), + onSettledContainer = Color(0xFF261C04), + warning = Color(0xFF8A5522), + onWarning = Color(0xFFFFFFFF), + warningContainer = Color(0xFFF5E1CA), + onWarningContainer = Color(0xFF2C1800), + pending = Color(0xFF3C6680), + card = Color(0xFFFFFFFF), + onCat = Color(0xFFFFFFFF), + shadow = Color(0x143C2819), + settlementRow = Color(0xFFFBF6F3), + settlementHero = Color(0xFFF8EEEA), + syncPillPending = Color(0xFFE1E4E3), + pendingBadge = Color(0xFFE4EAED), + chartGrid = Color(0xFFE6E0D8), + chartAxisLabel = Color(0xFF4E4842), + avatars = listOf(Color(0xFF8A5A33) to Color(0xFFFFFFFF), Color(0xFF4A6587) to Color(0xFFFFFFFF)), + categories = categories( + ColorToken.GREEN to cat(0xFF4E7A55, 0xFFDBE1D6), + ColorToken.ORANGE to cat(0xFFB26A3E, 0xFFEDDED2), + ColorToken.BROWN to cat(0xFF6B5244, 0xFFE0D9D3), + ColorToken.YELLOW to cat(0xFF9C7C2A, 0xFFE9E1CE), + ColorToken.BLUE to cat(0xFF4A78A8, 0xFFDAE0E5), + ColorToken.TEAL to cat(0xFF367F82, 0xFFD7E1DE), + ColorToken.PURPLE to cat(0xFF865891, 0xFFE5DAE1), + ColorToken.RED to cat(0xFFA84B45, 0xFFEBD8D3), + ColorToken.PINK to cat(0xFFAE5D7C, 0xFFECDBDD), + ColorToken.GREY to cat(0xFF6C7177, 0xFFE0DFDC), + ColorToken.INDIGO to cat(0xFF45508D, 0xFFD9D9E0), + ColorToken.LIME to cat(0xFF73822F, 0xFFE2E2CF), + ColorToken.SLATE to cat(0xFF5E2D4E, 0xFFDED3D4), + ColorToken.AMBER to cat(0xFF937D63, 0xFFE7E1D8), + ), + chartCats = listOf(0xFFA75B40, 0xFF5E7A4E, 0xFF8B6A2C, 0xFF4C6E98, 0xFF7C5A88, 0xFF3F7774, 0xFFA2566F, 0xFF6A6F74).map(::Color), + accountHeaders = listOf(0xFFF4EBE8, 0xFFECEFEA, 0xFFF1EDE6, 0xFFEAEEF3, 0xFFEFEBF1, 0xFFE8EFEE, 0xFFF4EBEE, 0xFFEDEEEE).map(::Color), + chartSeq = listOf(0xFFF2DFD6, 0xFFE2B7A5, 0xFFC08068, 0xFFA75B40, 0xFF763A26).map(::Color), +) + +val KvittDarkColors = KvittColors( + owed = Color(0xFFA2CCB0), + onOwed = Color(0xFF10331F), + owedContainer = Color(0xFF2E4A37), + onOwedContainer = Color(0xFFCBE7D3), + owing = Color(0xFFD8B5D6), + onOwing = Color(0xFF3C1F3C), + owingContainer = Color(0xFF553A55), + onOwingContainer = Color(0xFFF1DDEF), + settled = Color(0xFFE0CA94), + settledContainer = Color(0xFF55432E), + onSettledContainer = Color(0xFFF1E3C4), + warning = Color(0xFFEDBE8C), + onWarning = Color(0xFF452B0E), + warningContainer = Color(0xFF5B3F24), + onWarningContainer = Color(0xFFF6DFC6), + pending = Color(0xFFA3C7DE), + card = Color(0xFF272422), + onCat = Color(0xFF1B1917), + shadow = Color(0x40000000), + settlementRow = Color(0xFF3A2A23), + settlementHero = Color(0xFF482E24), + syncPillPending = Color(0xFF2D3031), + pendingBadge = Color(0xFF383B3C), + chartGrid = Color(0xFF36322F), + chartAxisLabel = Color(0xFFCDC5BC), + avatars = listOf(Color(0xFFDDB693) to Color(0xFF3A230F), Color(0xFFADC3DD) to Color(0xFF1C2F45)), + categories = categories( + ColorToken.GREEN to cat(0xFF8FB896, 0xFF373F35), + ColorToken.ORANGE to cat(0xFFE8AA7E, 0xFF4C3C30), + ColorToken.BROWN to cat(0xFFB8A090, 0xFF413934), + ColorToken.YELLOW to cat(0xFFE6C67A, 0xFF4C432F), + ColorToken.BLUE to cat(0xFF98C0E6, 0xFF394149), + ColorToken.TEAL to cat(0xFF8CCAC8, 0xFF364341), + ColorToken.PURPLE to cat(0xFFC59CDA, 0xFF443846), + ColorToken.RED to cat(0xFFDC8F86, 0xFF493532), + ColorToken.PINK to cat(0xFFEFB0BE, 0xFF4E3D3F), + ColorToken.GREY to cat(0xFFB0B2B3, 0xFF3F3E3C), + ColorToken.INDIGO to cat(0xFF969DD8, 0xFF393945), + ColorToken.LIME to cat(0xFFB9CC86, 0xFF414432), + ColorToken.SLATE to cat(0xFFB0719A, 0xFF3F2E36), + ColorToken.AMBER to cat(0xFFDFCFC0, 0xFF4A4540), + ), + chartCats = listOf(0xFFE3A68E, 0xFFABC79A, 0xFFD8BD7E, 0xFF9EB9DC, 0xFFC8A6D2, 0xFF8FC2BC, 0xFFE3A6BA, 0xFFB5B9BD).map(::Color), + accountHeaders = listOf(0xFF3E342F, 0xFF373830, 0xFF3C362D, 0xFF353638, 0xFF3A3437, 0xFF333734, 0xFF3E3434, 0xFF383635).map(::Color), + chartSeq = listOf(0xFF3E2C25, 0xFF6A4234, 0xFF9A6049, 0xFFCC8A70, 0xFFE6B5A2).map(::Color), +) + +/** The 14 pennant colours of the settled hero's bunting, left to right (design-spec §3.6). */ +val BuntingTokens: List = listOf( + ColorToken.GREEN, ColorToken.ORANGE, ColorToken.YELLOW, ColorToken.BLUE, ColorToken.TEAL, ColorToken.PURPLE, ColorToken.RED, + ColorToken.PINK, ColorToken.LIME, ColorToken.ORANGE, ColorToken.GREEN, ColorToken.BLUE, ColorToken.YELLOW, ColorToken.PINK, +) + +internal val LocalKvittColors = staticCompositionLocalOf { KvittLightColors } diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt new file mode 100644 index 0000000..a704bcc --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittDimens.kt @@ -0,0 +1,225 @@ +package se.hagstedt.kvitt.design.theme + +import androidx.compose.ui.unit.dp + +/** + * Component measures of docs/ux/design-spec.md §3 (anatomy, sizes). Components read their sizes from here so no size + * is written down anywhere else; the spacing scale is [KvittSpacing]. + */ +object KvittDimens { + // Icons (§2.12): 24 default, 18 in chips, 15–16 in row meta, 20 in buttons. + val icon = 24.dp + val iconChip = 18.dp + val iconMeta = 15.dp + val iconHint = 16.dp + val iconButton = 20.dp + val iconBadge = 14.dp + val iconBanner = 24.dp + + // Avatars (§3.3). + val avatarRow = 28.dp + val avatarSmall = 20.dp + val avatarChip = 24.dp + val avatarSplit = 32.dp + val avatarDirection = 40.dp + val avatarSeesaw = 46.dp + val avatarSettings = 52.dp + val avatarIntro = 72.dp + val avatarProfileStep = 112.dp + val avatarProfile = 128.dp + const val INITIALS_RATIO = 0.44f + val uploadRing = 4.dp + const val UPLOADING_ALPHA = 0.6f + val placeholderBorder = 2.dp + const val PLACEHOLDER_ICON_RATIO = 0.5f + + // Category and account tiles (§3.4). + val tileBreakdown = 32.dp + val tileSmall = 36.dp + val tileLedger = 40.dp + val tileEditor = 52.dp + val tileHeader = 56.dp + val tileHero = 60.dp + val tilePreview = 64.dp + const val TILE_ICON_RATIO = 0.52f + const val ARCHIVED_SATURATION = 0.25f + const val ARCHIVED_ALPHA = 0.75f + val accountTile = 44.dp + const val ACCOUNT_TILE_MUTED_ALPHA = 0.7f + const val ACCOUNT_CARD_ARCHIVED_ALPHA = 0.92f + val accountCardMin = 76.dp + val accountCardVertical = 14.dp + val accountCardSettledIcon = 20.dp + + // Pickers (§3.4). + const val ICON_PICKER_COLUMNS = 7 + val iconPickerGap = 5.dp + val iconPickerCell = 48.dp + val swatch = 44.dp + val swatchGap = 8.dp + val swatchCheck = 22.dp + val swatchInnerRing = 3.dp + val swatchOuterRing = 2.dp + + // Ledger (§3.5). + val monthHeaderTop = 12.dp + val monthHeaderSide = 8.dp + val monthHeaderBottom = 2.dp + val pendingMarker = 15.dp + + // Balance hero (§3.6). + val heroTop = 16.dp + val heroSide = 20.dp + val heroBottom = 20.dp + val heroSettledTop = 28.dp + val seesawWidth = 250.dp + val seesawHeight = 92.dp + val plankLength = 220.dp + val plankThickness = 8.dp + val fulcrumWidth = 36.dp + val fulcrumHeight = 22.dp + val seesawArm = 88.dp + const val SEESAW_TILT_DEGREES = 9f + const val SEESAW_WAITING_DEGREES = 12f + val buntingHeight = 30.dp + val buntingLine = 1.5.dp + val buttonGapAbove = 14.dp + val loadingIndicator = 56.dp + val loadingTile = 72.dp + val loadingVertical = 28.dp + val accountHeaderTop = 18.dp + val accountHeaderSettledIcon = 28.dp + + // Sync pill and banners (§3.6). + val pillMinHeight = 40.dp + val pillTop = 6.dp + val pillEnd = 10.dp + val pillStart = 14.dp + val bannerTop = 14.dp + val bannerSide = 16.dp + val bannerBottom = 10.dp + val bannerGap = 6.dp + val hintVertical = 4.dp + + // Editor fields (§3.7). + val amountTop = 10.dp + val amountBottom = 6.dp + val caretWidth = 3.dp + val keypadKey = 52.dp + val keypadKeyCompact = 46.dp + val keypadDeleteIcon = 26.dp + const val KEYPAD_COLUMNS = 3 + val keypadPadding = 12.dp + val keypadBottom = 8.dp + val fieldFocusOutline = 2.dp + const val QUICK_ROW_COLUMNS = 6 + const val QUICK_ROW_COLUMNS_LARGE = 3 + const val QUICK_ROW_TILES = 5 + const val GRID_SHEET_COLUMNS = 4 + const val GRID_SHEET_COLUMNS_LARGE = 3 + const val LARGE_FONT_SCALE = 1.5f + val quickRowGap = 4.dp + val quickRowLargeRowGap = 12.dp + val allIcon = 26.dp + val checkBadge = 20.dp + val checkBadgeRing = 2.dp + val checkBadgeOffsetX = 6.dp + val checkBadgeOffsetY = 8.dp + val chipMinHeight = 36.dp + val chipTop = 4.dp + val chipEnd = 10.dp + val chipStart = 8.dp + val chipGap = 6.dp + val accountDot = 12.dp + val splitBar = 16.dp + val splitBarThin = 8.dp + val splitGap = 3.dp + val splitGapKnob = 6.dp + val knobWidth = 4.dp + val knobHeight = 44.dp + val previewBar = 72.dp + val exactFieldBorder = 1.dp + val presetMinHeight = 40.dp + val fullPickerMinHeight = 52.dp + val avatarFullPicker = 28.dp + const val NOTES_MIN_LINES = 3 + const val NOTES_MAX_LINES = 8 + val compactTop = 6.dp + val compactSide = 20.dp + val compactBottom = 10.dp + val divider = 1.dp + + // Settle (§3.8). + val directionPadding = 14.dp + val directionStart = 18.dp + val allocationVertical = 12.dp + val allocationHorizontal = 14.dp + val allocationField = 148.dp + val allocationFieldTop = 4.dp + val overSettleBorder = 2.dp + val stripVertical = 8.dp + val stripHorizontal = 12.dp + val remainingVertical = 10.dp + val remainingHorizontal = 14.dp + + // Timeline (§3.9). + val timelineGutter = 24.dp + val timelineDot = 12.dp + val timelineConnector = 2.dp + val timelineBottom = 12.dp + val timelineItemGap = 14.dp + + // Charts (§3.10). + val chartPlot = 170.dp + val chartAxisGutter = 44.dp + val chartBarGap = 10.dp + val chartSegmentGap = 2.dp + val chartMinBar = 16.dp + val chartMaxBar = 56.dp + val chartGridLine = 1.dp + val chartXLabelTop = 6.dp + val chartCardTop = 18.dp + val legendSwatch = 10.dp + val legendRowGap = 4.dp + val legendItemGap = 12.dp + val breakdownBar = 10.dp + val breakdownIndent = 44.dp + val filterButtonMin = 40.dp + val filterToggleMinWidth = 170.dp + + // Scaffolding (§3.11). + val wordmarkMark = 22.dp + val wordmarkIcon = 15.dp + val navIndicatorWidth = 56.dp + val navIndicatorHeight = 32.dp + val fabHeight = 56.dp + val buttonSmall = 40.dp + val buttonMedium = 56.dp + val buttonSmallHorizontal = 20.dp + val buttonMediumHorizontal = 26.dp + val buttonMediumIcon = 24.dp + val badgeVertical = 2.dp + val badgeHorizontal = 10.dp + val sectionHeaderStart = 4.dp + val collapsibleVertical = 6.dp + val emptyVertical = 28.dp + val emptyHorizontal = 24.dp + val emptyGap = 10.dp + val illustrationWidth = 170.dp + val illustrationHeight = 110.dp + val dialogIconTile = 56.dp + val dialogPadding = 24.dp + val dialogGap = 14.dp + val menuMinWidth = 244.dp + val menuMaxWidth = 300.dp + val menuPadding = 6.dp + const val DISABLED_REASON_ALPHA = 0.5f + val inviteChipVertical = 6.dp + val inviteChipHorizontal = 10.dp + val settingsTileIcon = 22.dp + val cardPadding = 16.dp + val cardPaddingLarge = 20.dp + const val DISABLED_CONTENT_ALPHA = 0.38f + const val DISABLED_CONTAINER_ALPHA = 0.12f + const val DELETED_ALPHA = 0.62f +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittMotion.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittMotion.kt new file mode 100644 index 0000000..1459adf --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittMotion.kt @@ -0,0 +1,60 @@ +package se.hagstedt.kvitt.design.theme + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.ui.unit.dp + +// Motion of docs/ux/design-spec.md §2.11. +// +// Expressive status against the pinned Material 3 1.4.0 (design-spec §3.1, §6 open point 5): `MotionScheme`, +// `MotionScheme.expressive()` and the `MaterialTheme(motionScheme = …)` overload are internal in 1.4.0, so neither the +// Expressive scheme nor a custom one can be installed. Fallback: Material components keep 1.4.0's own (standard) +// motion, and Kvitt's components animate with the springs below, which carry the Expressive values of §2.11. + +/** Spring parameters and the named motions of design-spec §2.11. */ +object KvittMotion { + const val SPATIAL_FAST_DAMPING = 0.6f + const val SPATIAL_FAST_STIFFNESS = 800f + const val SPATIAL_DEFAULT_DAMPING = 0.8f + const val SPATIAL_DEFAULT_STIFFNESS = 380f + const val SPATIAL_SLOW_DAMPING = 0.8f + const val SPATIAL_SLOW_STIFFNESS = 200f + const val EFFECTS_FAST_STIFFNESS = 3800f + const val EFFECTS_DEFAULT_STIFFNESS = 1600f + const val EFFECTS_SLOW_STIFFNESS = 800f + + /** Seesaw re-tilt when the global balance changes. */ + const val SEESAW_DAMPING = 0.55f + const val SEESAW_STIFFNESS = 300f + + /** The hero amount counts from the old to the new value over this many milliseconds, emphasized decelerate. */ + const val HERO_COUNT_MILLIS = 400 + val EmphasizedDecelerate = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1f) + + /** Settling to zero: the bunting drops one pennant at a time with this stagger. */ + const val BUNTING_STAGGER_MILLIS = 40 + + /** Pushed screens: horizontal shared axis, slide distance. S-7 and S-9 enter from the bottom by this fraction. */ + val SharedAxisSlide = 30.dp + const val SHEET_ENTER_FRACTION = 0.10f + + /** Keypad swap for the system keyboard (design-spec §5.2). */ + const val KEYPAD_SWAP_MILLIS = 150 + + fun fastSpatial(): FiniteAnimationSpec = spring(SPATIAL_FAST_DAMPING, SPATIAL_FAST_STIFFNESS) + fun defaultSpatial(): FiniteAnimationSpec = spring(SPATIAL_DEFAULT_DAMPING, SPATIAL_DEFAULT_STIFFNESS) + fun slowSpatial(): FiniteAnimationSpec = spring(SPATIAL_SLOW_DAMPING, SPATIAL_SLOW_STIFFNESS) + fun fastEffects(): FiniteAnimationSpec = spring(Spring.DampingRatioNoBouncy, EFFECTS_FAST_STIFFNESS) + fun defaultEffects(): FiniteAnimationSpec = spring(Spring.DampingRatioNoBouncy, EFFECTS_DEFAULT_STIFFNESS) + fun slowEffects(): FiniteAnimationSpec = spring(Spring.DampingRatioNoBouncy, EFFECTS_SLOW_STIFFNESS) + + fun seesaw(): FiniteAnimationSpec = spring(SEESAW_DAMPING, SEESAW_STIFFNESS) + + /** + * Reduced motion (system "remove animations", animator duration scale 0): every spring becomes critically damped + * and immediate (design-spec §2.11). + */ + fun reduced(): FiniteAnimationSpec = spring(Spring.DampingRatioNoBouncy, Spring.StiffnessHigh) +} diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittShapes.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittShapes.kt new file mode 100644 index 0000000..04d8666 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittShapes.kt @@ -0,0 +1,87 @@ +package se.hagstedt.kvitt.design.theme + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp + +// Shapes of docs/ux/design-spec.md §2.9. +// +// Expressive status against the pinned Material 3 1.4.0 (design-spec §3.1, §6 open point 5): `MaterialShapes` +// (Cookie4Sided, Clover4Leaf, Sunny, Cookie9Sided) is not part of the 1.4.0 release, and `Shapes.largeIncreased`, +// `extraLargeIncreased` and `extraExtraLarge` are internal there. Every shape below therefore uses the stable +// fallback the spec names; the swap to MaterialShapes happens here and nowhere else. + +/** Material 3 shape scale: extraSmall 4, small 8, medium 12, large 16, extraLarge 28. */ +internal val KvittMaterialShapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(28.dp), +) + +/** Kvitt's named shapes (design-spec §2.9, §2.14). */ +@Immutable +data class KvittShapes( + /** largeIncreased 20: the FAB. */ + val largeIncreased: CornerBasedShape = RoundedCornerShape(20.dp), + /** extraLargeIncreased 32. */ + val extraLargeIncreased: CornerBasedShape = RoundedCornerShape(32.dp), + /** extraExtraLarge 48. */ + val extraExtraLarge: CornerBasedShape = RoundedCornerShape(48.dp), + /** Category tile: MaterialShapes.Cookie4Sided, fallback 30 % rounded. */ + val categoryTile: Shape = RoundedCornerShape(30), + /** Account tile: MaterialShapes.Clover4Leaf, fallback 16 dp rounded. */ + val accountTile: Shape = RoundedCornerShape(16.dp), + /** Large standalone avatars, app mark, dialog icon tile, status tile: MaterialShapes.Sunny, fallback circle. */ + val avatarLarge: Shape = CircleShape, + /** Illustrations and the loading indicator container: MaterialShapes.Cookie9Sided, fallback circle. */ + val illustration: Shape = CircleShape, + /** Cards, balance hero, dialogs, flat sections: extraLarge 28. */ + val card: CornerBasedShape = RoundedCornerShape(28.dp), + /** Account cards, banners, segmented-group outer corners: 24. */ + val accountCard: CornerBasedShape = RoundedCornerShape(24.dp), + /** Segmented-group outer corner radius and inner corner radius (8). */ + val groupOuterRadius: Float = 24f, + val groupInnerRadius: Float = 8f, + /** Text fields: large 16 on all four corners. */ + val field: CornerBasedShape = RoundedCornerShape(16.dp), + /** Allocation amount field: 14. */ + val allocationField: CornerBasedShape = RoundedCornerShape(14.dp), + /** Warning strips and menu items: medium 12. */ + val strip: CornerBasedShape = RoundedCornerShape(12.dp), + /** Menus and info boxes: large 16. */ + val menu: CornerBasedShape = RoundedCornerShape(16.dp), + /** FAB: largeIncreased 20. */ + val fab: CornerBasedShape = RoundedCornerShape(20.dp), + /** Keypad sheet: top corners 28. */ + val sheetTop: CornerBasedShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + /** Chart bars: top 10, bottom 4. */ + val chartBarTopRadius: Float = 10f, + val chartBarBottomRadius: Float = 4f, + /** Legend swatch: radius 3. */ + val legendSwatch: CornerBasedShape = RoundedCornerShape(3.dp), + /** Invite code chips: radius 14. */ + val inviteChip: CornerBasedShape = RoundedCornerShape(14.dp), + /** Skeleton blocks: radius 12. */ + val skeleton: CornerBasedShape = RoundedCornerShape(12.dp), +) { + /** + * Shape of item [index] of [count] in a segmented group: outer corners 24 on the first item's top and the last + * item's bottom, inner corners 8 elsewhere (design-spec §3.5). + */ + fun groupItem(index: Int, count: Int): Shape { + val outer = groupOuterRadius.dp + val inner = groupInnerRadius.dp + val top = if (index == 0) outer else inner + val bottom = if (index == count - 1) outer else inner + return RoundedCornerShape(topStart = top, topEnd = top, bottomStart = bottom, bottomEnd = bottom) + } +} + +internal val LocalKvittShapes = staticCompositionLocalOf { KvittShapes() } diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittSpacing.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittSpacing.kt new file mode 100644 index 0000000..793e468 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittSpacing.kt @@ -0,0 +1,46 @@ +package se.hagstedt.kvitt.design.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** Spacing scale and layout measures of docs/ux/design-spec.md §2.8. */ +@Immutable +data class KvittSpacing( + val s1: Dp = 4.dp, + val s2: Dp = 8.dp, + val s3: Dp = 12.dp, + val s4: Dp = 16.dp, + val s5: Dp = 20.dp, + val s6: Dp = 24.dp, + val s8: Dp = 32.dp, + val s10: Dp = 40.dp, + val s12: Dp = 48.dp, + /** Screen side padding. */ + val gutter: Dp = 16.dp, + /** Gap between cards in a scrolling column (S-4, S-7). */ + val sectionGap: Dp = 12.dp, + /** Gap between cards on detail screens, S-9, S-11 and settings sub-screens. */ + val detailGap: Dp = 10.dp, + /** Gap between the items of a segmented group. */ + val groupGap: Dp = 3.dp, + /** Row padding: 10 vertical, 16 horizontal. */ + val rowVertical: Dp = 10.dp, + val rowHorizontal: Dp = 16.dp, + /** Gap between a leading tile and text: 14 in rows, 12 in cards. */ + val rowTileGap: Dp = 14.dp, + val cardTileGap: Dp = 12.dp, + /** Minimum heights: settings and fact rows 56, ledger rows 72. */ + val rowMin: Dp = 56.dp, + val ledgerRowMin: Dp = 72.dp, + /** Bottom padding of a scrolling column under a FAB. */ + val fabClearance: Dp = 120.dp, + /** Top app bar height; bottom navigation height (plus the system inset). */ + val topAppBarHeight: Dp = 64.dp, + val navigationBarHeight: Dp = 64.dp, + /** Minimum touch target. */ + val touchTarget: Dp = 48.dp, +) + +internal val LocalKvittSpacing = staticCompositionLocalOf { KvittSpacing() } diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittTypography.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittTypography.kt new file mode 100644 index 0000000..c4e2259 --- /dev/null +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittTypography.kt @@ -0,0 +1,114 @@ +package se.hagstedt.kvitt.design.theme + +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.Hyphens +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// Type scale of docs/ux/design-spec.md §2.7. Typeface: the Material 3 default (FontFamily.Default), no bundled font (D-020). + +/** OpenType feature that keeps figures the same width so amounts line up (design-spec §2.7). */ +internal const val TABULAR_FIGURES = "tnum" + +private fun style(size: Int, line: Int, weight: Int, tracking: Double, tabular: Boolean = false): TextStyle = TextStyle( + fontFamily = FontFamily.Default, + fontSize = size.sp, + lineHeight = line.sp, + fontWeight = FontWeight(weight), + letterSpacing = tracking.sp, + fontFeatureSettings = if (tabular) TABULAR_FIGURES else null, +) + +internal val KvittMaterialTypography = Typography( + displayLarge = style(57, 64, 500, -0.25), + displayMedium = style(45, 52, 500, 0.0), + displaySmall = style(36, 44, 600, 0.0), + headlineLarge = style(32, 40, 600, 0.0), + headlineMedium = style(28, 36, 600, 0.0), + headlineSmall = style(24, 32, 600, 0.0), + titleLarge = style(22, 28, 600, 0.0), + titleMedium = style(16, 24, 600, 0.15), + titleSmall = style(14, 20, 600, 0.1), + bodyLarge = style(16, 24, 400, 0.5), + bodyMedium = style(14, 20, 400, 0.25), + bodySmall = style(12, 16, 400, 0.4), + labelLarge = style(14, 20, 600, 0.1), + labelMedium = style(12, 16, 500, 0.5), + labelSmall = style(11, 16, 500, 0.5), +) + +/** + * Kvitt's styles beyond the Material scale: the four amount styles with tabular figures, the units beside large + * amounts, and the small fixed styles the spec names per component (design-spec §2.7, §3). + */ +@Immutable +data class KvittTypography( + val amountHero: TextStyle = style(44, 52, 700, -0.5, tabular = true), + val amountLarge: TextStyle = style(28, 36, 700, -0.25, tabular = true), + val amountMedium: TextStyle = style(18, 24, 700, 0.0, tabular = true), + val amountSmall: TextStyle = style(15, 20, 600, 0.0, tabular = true), + /** "kr" beside the hero amount: 24 / 30 sp, weight 700, in the amount's colour. */ + val heroUnit: TextStyle = style(24, 30, 700, 0.0, tabular = true), + /** Category tile labels in the quick row and grid: 11 / 14 sp, weight 500. */ + val tileLabel: TextStyle = style(11, 14, 500, 0.0).copy(hyphens = Hyphens.Auto, lineBreak = LineBreak.Paragraph), + /** Seesaw name labels: 12 sp, weight 600 (size capped at 1.15× by the component). */ + val seesawName: TextStyle = style(12, 16, 600, 0.0), + /** Account tile letter: 18 sp, weight 700 (capped at 1.3×). */ + val accountTileLetter: TextStyle = style(18, 22, 700, 0.0), + /** Sync pill text: 13 sp, weight 500. */ + val syncPill: TextStyle = style(13, 18, 500, 0.0), + /** Editor chips: 14 / 18 sp, weight 500 (key prefix weight 600). */ + val chip: TextStyle = style(14, 18, 500, 0.0), + /** Keypad keys: 24 sp, weight 600 (capped at 1.5×). */ + val keypadKey: TextStyle = style(24, 30, 600, 0.0, tabular = true), + /** Wordmark "Kvitt" in the top app bar: 24 sp, weight 700 (capped at 1.5×). */ + val wordmark: TextStyle = style(24, 30, 700, 0.0), + /** Extended FAB label: 16 / 22 sp, weight 600. */ + val fab: TextStyle = style(16, 22, 600, 0.0), + /** Medium button label: 16 sp, weight 600. */ + val buttonMedium: TextStyle = style(16, 22, 600, 0.1), + /** Chart axis labels: 10 / 12 sp; x labels 11 / 14 sp; legend 12 / 16 sp weight 500. */ + val chartAxis: TextStyle = style(10, 12, 400, 0.0, tabular = true), + val chartXLabel: TextStyle = style(11, 14, 400, 0.0), + val chartLegend: TextStyle = style(12, 16, 500, 0.0), + /** Invite code groups: 24 / 32 sp, weight 700, letter spacing 1.5. */ + val inviteCode: TextStyle = style(24, 32, 700, 1.5, tabular = true), +) + +/** Size limits of design-spec §2.7: these labels grow with the font scale only up to the factor given. */ +object KvittFontCaps { + const val NAVIGATION_LABEL = 1.6f + const val SEESAW_NAME = 1.15f + const val ACCOUNT_TILE_LETTER = 1.3f + const val AVATAR_INITIALS = 1.3f + const val KEYPAD_KEY = 1.5f + const val WORDMARK = 1.5f +} + +/** + * A text size that the font scale enlarges at most by [cap], computed from a dp size as design-spec §2.7 prescribes: + * `(size.dp * min(fontScale, cap)).toSp()`. + */ +@Composable +@ReadOnlyComposable +fun cappedTextSize(size: TextUnit, cap: Float): TextUnit { + val density = LocalDensity.current + return with(density) { (size.value.dp * minOf(density.fontScale, cap)).toSp() } +} + +/** This style with its size (and line height) capped per [cappedTextSize]. */ +@Composable +@ReadOnlyComposable +fun TextStyle.capped(cap: Float): TextStyle = copy(fontSize = cappedTextSize(fontSize, cap), lineHeight = cappedTextSize(lineHeight, cap)) + +internal val LocalKvittTypography = staticCompositionLocalOf { KvittTypography() } diff --git a/android/core/design/src/main/res/drawable/ic_account_balance.xml b/android/core/design/src/main/res/drawable/ic_account_balance.xml new file mode 100644 index 0000000..f4bb9e3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_account_balance.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_account_balance_filled.xml b/android/core/design/src/main/res/drawable/ic_account_balance_filled.xml new file mode 100644 index 0000000..4c786e5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_account_balance_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_add.xml b/android/core/design/src/main/res/drawable/ic_add.xml new file mode 100644 index 0000000..4ecb05b --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_add.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_add_a_photo.xml b/android/core/design/src/main/res/drawable/ic_add_a_photo.xml new file mode 100644 index 0000000..62aaaec --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_add_a_photo.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_add_a_photo_filled.xml b/android/core/design/src/main/res/drawable/ic_add_a_photo_filled.xml new file mode 100644 index 0000000..3abfa58 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_add_a_photo_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_add_filled.xml b/android/core/design/src/main/res/drawable/ic_add_filled.xml new file mode 100644 index 0000000..ead7ee6 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_add_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_add_link.xml b/android/core/design/src/main/res/drawable/ic_add_link.xml new file mode 100644 index 0000000..0253481 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_add_link.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_add_link_filled.xml b/android/core/design/src/main/res/drawable/ic_add_link_filled.xml new file mode 100644 index 0000000..0f9a3bf --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_add_link_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_apps.xml b/android/core/design/src/main/res/drawable/ic_apps.xml new file mode 100644 index 0000000..6e824a4 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_apps.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_apps_filled.xml b/android/core/design/src/main/res/drawable/ic_apps_filled.xml new file mode 100644 index 0000000..1f14431 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_apps_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_arrow_back.xml b/android/core/design/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 0000000..38288ac --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_arrow_back_filled.xml b/android/core/design/src/main/res/drawable/ic_arrow_back_filled.xml new file mode 100644 index 0000000..f2599d0 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_arrow_back_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_arrow_forward.xml b/android/core/design/src/main/res/drawable/ic_arrow_forward.xml new file mode 100644 index 0000000..2c57274 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_arrow_forward.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_arrow_forward_filled.xml b/android/core/design/src/main/res/drawable/ic_arrow_forward_filled.xml new file mode 100644 index 0000000..d2231f6 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_arrow_forward_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_backspace.xml b/android/core/design/src/main/res/drawable/ic_backspace.xml new file mode 100644 index 0000000..9ca7562 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_backspace.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_backspace_filled.xml b/android/core/design/src/main/res/drawable/ic_backspace_filled.xml new file mode 100644 index 0000000..e29a096 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_backspace_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_bar_chart.xml b/android/core/design/src/main/res/drawable/ic_bar_chart.xml new file mode 100644 index 0000000..91b4b7a --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_bar_chart.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_bar_chart_filled.xml b/android/core/design/src/main/res/drawable/ic_bar_chart_filled.xml new file mode 100644 index 0000000..4751b02 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_bar_chart_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_block.xml b/android/core/design/src/main/res/drawable/ic_block.xml new file mode 100644 index 0000000..df528fd --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_block.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_block_filled.xml b/android/core/design/src/main/res/drawable/ic_block_filled.xml new file mode 100644 index 0000000..3150185 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_block_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_bolt.xml b/android/core/design/src/main/res/drawable/ic_bolt.xml new file mode 100644 index 0000000..5182225 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_bolt.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_bolt_filled.xml b/android/core/design/src/main/res/drawable/ic_bolt_filled.xml new file mode 100644 index 0000000..8e92d64 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_bolt_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_build.xml b/android/core/design/src/main/res/drawable/ic_build.xml new file mode 100644 index 0000000..209c7ac --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_build.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_build_filled.xml b/android/core/design/src/main/res/drawable/ic_build_filled.xml new file mode 100644 index 0000000..1761948 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_build_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_calculate.xml b/android/core/design/src/main/res/drawable/ic_calculate.xml new file mode 100644 index 0000000..5528adb --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_calculate.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_calculate_filled.xml b/android/core/design/src/main/res/drawable/ic_calculate_filled.xml new file mode 100644 index 0000000..2e06e16 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_calculate_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_call_split.xml b/android/core/design/src/main/res/drawable/ic_call_split.xml new file mode 100644 index 0000000..d1dfc5a --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_call_split.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_call_split_filled.xml b/android/core/design/src/main/res/drawable/ic_call_split_filled.xml new file mode 100644 index 0000000..57f2770 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_call_split_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_card_giftcard.xml b/android/core/design/src/main/res/drawable/ic_card_giftcard.xml new file mode 100644 index 0000000..9fc6be8 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_card_giftcard.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_card_giftcard_filled.xml b/android/core/design/src/main/res/drawable/ic_card_giftcard_filled.xml new file mode 100644 index 0000000..40db2eb --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_card_giftcard_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_category.xml b/android/core/design/src/main/res/drawable/ic_category.xml new file mode 100644 index 0000000..8525c50 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_category.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_category_filled.xml b/android/core/design/src/main/res/drawable/ic_category_filled.xml new file mode 100644 index 0000000..5b1f46b --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_category_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_celebration.xml b/android/core/design/src/main/res/drawable/ic_celebration.xml new file mode 100644 index 0000000..e7b9165 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_celebration.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_celebration_filled.xml b/android/core/design/src/main/res/drawable/ic_celebration_filled.xml new file mode 100644 index 0000000..56e1fae --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_celebration_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_chair.xml b/android/core/design/src/main/res/drawable/ic_chair.xml new file mode 100644 index 0000000..15c22e5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_chair.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_chair_filled.xml b/android/core/design/src/main/res/drawable/ic_chair_filled.xml new file mode 100644 index 0000000..8364f69 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_chair_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_check.xml b/android/core/design/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..effc34b --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_check.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_check_badge.xml b/android/core/design/src/main/res/drawable/ic_check_badge.xml new file mode 100644 index 0000000..ce48215 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_check_badge.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_check_circle.xml b/android/core/design/src/main/res/drawable/ic_check_circle.xml new file mode 100644 index 0000000..4f2395e --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_check_circle.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_check_circle_filled.xml b/android/core/design/src/main/res/drawable/ic_check_circle_filled.xml new file mode 100644 index 0000000..83d92cd --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_check_circle_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_check_filled.xml b/android/core/design/src/main/res/drawable/ic_check_filled.xml new file mode 100644 index 0000000..8d77ab4 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_check_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_checkroom.xml b/android/core/design/src/main/res/drawable/ic_checkroom.xml new file mode 100644 index 0000000..26da81d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_checkroom.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_checkroom_filled.xml b/android/core/design/src/main/res/drawable/ic_checkroom_filled.xml new file mode 100644 index 0000000..e37106f --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_checkroom_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_chevron_right.xml b/android/core/design/src/main/res/drawable/ic_chevron_right.xml new file mode 100644 index 0000000..4c4b703 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_chevron_right.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_chevron_right_filled.xml b/android/core/design/src/main/res/drawable/ic_chevron_right_filled.xml new file mode 100644 index 0000000..a81c314 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_chevron_right_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_child_care.xml b/android/core/design/src/main/res/drawable/ic_child_care.xml new file mode 100644 index 0000000..0bff296 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_child_care.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_child_care_filled.xml b/android/core/design/src/main/res/drawable/ic_child_care_filled.xml new file mode 100644 index 0000000..6e4fa40 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_child_care_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_close.xml b/android/core/design/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..d086b27 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_close.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_close_filled.xml b/android/core/design/src/main/res/drawable/ic_close_filled.xml new file mode 100644 index 0000000..656e8e5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_close_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_cloud_done.xml b/android/core/design/src/main/res/drawable/ic_cloud_done.xml new file mode 100644 index 0000000..4e40047 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_cloud_done.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_cloud_done_filled.xml b/android/core/design/src/main/res/drawable/ic_cloud_done_filled.xml new file mode 100644 index 0000000..41b1f50 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_cloud_done_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_cloud_off.xml b/android/core/design/src/main/res/drawable/ic_cloud_off.xml new file mode 100644 index 0000000..9ecd62f --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_cloud_off.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_cloud_off_filled.xml b/android/core/design/src/main/res/drawable/ic_cloud_off_filled.xml new file mode 100644 index 0000000..4036d1f --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_cloud_off_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_cloud_upload.xml b/android/core/design/src/main/res/drawable/ic_cloud_upload.xml new file mode 100644 index 0000000..c3ceba3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_cloud_upload.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_cloud_upload_filled.xml b/android/core/design/src/main/res/drawable/ic_cloud_upload_filled.xml new file mode 100644 index 0000000..c4f58d3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_cloud_upload_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_content_copy.xml b/android/core/design/src/main/res/drawable/ic_content_copy.xml new file mode 100644 index 0000000..033fb72 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_content_copy.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_content_copy_filled.xml b/android/core/design/src/main/res/drawable/ic_content_copy_filled.xml new file mode 100644 index 0000000..d593283 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_content_copy_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_content_paste.xml b/android/core/design/src/main/res/drawable/ic_content_paste.xml new file mode 100644 index 0000000..0155cb3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_content_paste.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_content_paste_filled.xml b/android/core/design/src/main/res/drawable/ic_content_paste_filled.xml new file mode 100644 index 0000000..5f1ac05 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_content_paste_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_date_range.xml b/android/core/design/src/main/res/drawable/ic_date_range.xml new file mode 100644 index 0000000..23134c5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_date_range.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_date_range_filled.xml b/android/core/design/src/main/res/drawable/ic_date_range_filled.xml new file mode 100644 index 0000000..f0001f8 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_date_range_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_delete.xml b/android/core/design/src/main/res/drawable/ic_delete.xml new file mode 100644 index 0000000..c949ef5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_delete.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_delete_filled.xml b/android/core/design/src/main/res/drawable/ic_delete_filled.xml new file mode 100644 index 0000000..36d7e40 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_delete_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_description.xml b/android/core/design/src/main/res/drawable/ic_description.xml new file mode 100644 index 0000000..03ad85e --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_description.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_description_filled.xml b/android/core/design/src/main/res/drawable/ic_description_filled.xml new file mode 100644 index 0000000..09e35ff --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_description_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_devices.xml b/android/core/design/src/main/res/drawable/ic_devices.xml new file mode 100644 index 0000000..8d06ee3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_devices.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_devices_filled.xml b/android/core/design/src/main/res/drawable/ic_devices_filled.xml new file mode 100644 index 0000000..3da7919 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_devices_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_directions_car.xml b/android/core/design/src/main/res/drawable/ic_directions_car.xml new file mode 100644 index 0000000..001a0f3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_directions_car.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_directions_car_filled.xml b/android/core/design/src/main/res/drawable/ic_directions_car_filled.xml new file mode 100644 index 0000000..872097c --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_directions_car_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_dns.xml b/android/core/design/src/main/res/drawable/ic_dns.xml new file mode 100644 index 0000000..460f06f --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_dns.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_dns_filled.xml b/android/core/design/src/main/res/drawable/ic_dns_filled.xml new file mode 100644 index 0000000..51614fe --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_dns_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_download.xml b/android/core/design/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..11105a5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_download.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_download_filled.xml b/android/core/design/src/main/res/drawable/ic_download_filled.xml new file mode 100644 index 0000000..3197c78 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_download_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_downloading.xml b/android/core/design/src/main/res/drawable/ic_downloading.xml new file mode 100644 index 0000000..9e0db24 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_downloading.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_downloading_filled.xml b/android/core/design/src/main/res/drawable/ic_downloading_filled.xml new file mode 100644 index 0000000..2cd8640 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_downloading_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_drag_indicator.xml b/android/core/design/src/main/res/drawable/ic_drag_indicator.xml new file mode 100644 index 0000000..3b84bd1 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_drag_indicator.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_drag_indicator_filled.xml b/android/core/design/src/main/res/drawable/ic_drag_indicator_filled.xml new file mode 100644 index 0000000..1b7e0c9 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_drag_indicator_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_edit.xml b/android/core/design/src/main/res/drawable/ic_edit.xml new file mode 100644 index 0000000..7c08d47 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_edit.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_edit_filled.xml b/android/core/design/src/main/res/drawable/ic_edit_filled.xml new file mode 100644 index 0000000..dd25cd9 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_edit_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_error.xml b/android/core/design/src/main/res/drawable/ic_error.xml new file mode 100644 index 0000000..01c9ab7 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_error.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_error_filled.xml b/android/core/design/src/main/res/drawable/ic_error_filled.xml new file mode 100644 index 0000000..51b63d3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_error_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_event.xml b/android/core/design/src/main/res/drawable/ic_event.xml new file mode 100644 index 0000000..f93c93d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_event.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_event_filled.xml b/android/core/design/src/main/res/drawable/ic_event_filled.xml new file mode 100644 index 0000000..25d2724 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_event_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_event_repeat.xml b/android/core/design/src/main/res/drawable/ic_event_repeat.xml new file mode 100644 index 0000000..43306d4 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_event_repeat.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_event_repeat_filled.xml b/android/core/design/src/main/res/drawable/ic_event_repeat_filled.xml new file mode 100644 index 0000000..11cac42 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_event_repeat_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_expand_less.xml b/android/core/design/src/main/res/drawable/ic_expand_less.xml new file mode 100644 index 0000000..54ee430 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_expand_less.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_expand_less_filled.xml b/android/core/design/src/main/res/drawable/ic_expand_less_filled.xml new file mode 100644 index 0000000..bda384d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_expand_less_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_expand_more.xml b/android/core/design/src/main/res/drawable/ic_expand_more.xml new file mode 100644 index 0000000..b05aa21 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_expand_more.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_expand_more_filled.xml b/android/core/design/src/main/res/drawable/ic_expand_more_filled.xml new file mode 100644 index 0000000..dd83b5b --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_expand_more_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_fitness_center.xml b/android/core/design/src/main/res/drawable/ic_fitness_center.xml new file mode 100644 index 0000000..d92b020 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_fitness_center.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_fitness_center_filled.xml b/android/core/design/src/main/res/drawable/ic_fitness_center_filled.xml new file mode 100644 index 0000000..f8a0da3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_fitness_center_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_flight.xml b/android/core/design/src/main/res/drawable/ic_flight.xml new file mode 100644 index 0000000..6514198 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_flight.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_flight_filled.xml b/android/core/design/src/main/res/drawable/ic_flight_filled.xml new file mode 100644 index 0000000..6e05e9d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_flight_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_handshake.xml b/android/core/design/src/main/res/drawable/ic_handshake.xml new file mode 100644 index 0000000..cfae423 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_handshake.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_handshake_filled.xml b/android/core/design/src/main/res/drawable/ic_handshake_filled.xml new file mode 100644 index 0000000..89a0d88 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_handshake_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_home.xml b/android/core/design/src/main/res/drawable/ic_home.xml new file mode 100644 index 0000000..3ba68a7 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_home.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_home_filled.xml b/android/core/design/src/main/res/drawable/ic_home_filled.xml new file mode 100644 index 0000000..f1f09a1 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_home_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_hotel.xml b/android/core/design/src/main/res/drawable/ic_hotel.xml new file mode 100644 index 0000000..3826b63 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_hotel.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_hotel_filled.xml b/android/core/design/src/main/res/drawable/ic_hotel_filled.xml new file mode 100644 index 0000000..203136d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_hotel_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_info.xml b/android/core/design/src/main/res/drawable/ic_info.xml new file mode 100644 index 0000000..e269ba0 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_info.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_info_filled.xml b/android/core/design/src/main/res/drawable/ic_info_filled.xml new file mode 100644 index 0000000..2ddace3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_info_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_install_mobile.xml b/android/core/design/src/main/res/drawable/ic_install_mobile.xml new file mode 100644 index 0000000..b4c6981 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_install_mobile.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_install_mobile_filled.xml b/android/core/design/src/main/res/drawable/ic_install_mobile_filled.xml new file mode 100644 index 0000000..9e5497b --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_install_mobile_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_inventory_2.xml b/android/core/design/src/main/res/drawable/ic_inventory_2.xml new file mode 100644 index 0000000..20dba30 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_inventory_2.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_inventory_2_filled.xml b/android/core/design/src/main/res/drawable/ic_inventory_2_filled.xml new file mode 100644 index 0000000..397919d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_inventory_2_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_key.xml b/android/core/design/src/main/res/drawable/ic_key.xml new file mode 100644 index 0000000..db77f7c --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_key.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_key_filled.xml b/android/core/design/src/main/res/drawable/ic_key_filled.xml new file mode 100644 index 0000000..3e12547 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_key_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_link.xml b/android/core/design/src/main/res/drawable/ic_link.xml new file mode 100644 index 0000000..17e1b2e --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_link.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_link_filled.xml b/android/core/design/src/main/res/drawable/ic_link_filled.xml new file mode 100644 index 0000000..506967c --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_link_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_local_cafe.xml b/android/core/design/src/main/res/drawable/ic_local_cafe.xml new file mode 100644 index 0000000..faeecf0 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_local_cafe.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_local_cafe_filled.xml b/android/core/design/src/main/res/drawable/ic_local_cafe_filled.xml new file mode 100644 index 0000000..9291a58 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_local_cafe_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_local_gas_station.xml b/android/core/design/src/main/res/drawable/ic_local_gas_station.xml new file mode 100644 index 0000000..47f85d1 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_local_gas_station.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_local_gas_station_filled.xml b/android/core/design/src/main/res/drawable/ic_local_gas_station_filled.xml new file mode 100644 index 0000000..1fe3b32 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_local_gas_station_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_lock.xml b/android/core/design/src/main/res/drawable/ic_lock.xml new file mode 100644 index 0000000..7a00885 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_lock.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_lock_filled.xml b/android/core/design/src/main/res/drawable/ic_lock_filled.xml new file mode 100644 index 0000000..9f23c52 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_lock_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_luggage.xml b/android/core/design/src/main/res/drawable/ic_luggage.xml new file mode 100644 index 0000000..7e4b2dc --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_luggage.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_luggage_filled.xml b/android/core/design/src/main/res/drawable/ic_luggage_filled.xml new file mode 100644 index 0000000..ea322c4 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_luggage_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_medical_services.xml b/android/core/design/src/main/res/drawable/ic_medical_services.xml new file mode 100644 index 0000000..daa7214 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_medical_services.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_medical_services_filled.xml b/android/core/design/src/main/res/drawable/ic_medical_services_filled.xml new file mode 100644 index 0000000..573a9f3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_medical_services_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_medication.xml b/android/core/design/src/main/res/drawable/ic_medication.xml new file mode 100644 index 0000000..5ef68fc --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_medication.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_medication_filled.xml b/android/core/design/src/main/res/drawable/ic_medication_filled.xml new file mode 100644 index 0000000..d9bda1e --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_medication_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_memory.xml b/android/core/design/src/main/res/drawable/ic_memory.xml new file mode 100644 index 0000000..0af600a --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_memory.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_memory_filled.xml b/android/core/design/src/main/res/drawable/ic_memory_filled.xml new file mode 100644 index 0000000..00c1e68 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_memory_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_more_vert.xml b/android/core/design/src/main/res/drawable/ic_more_vert.xml new file mode 100644 index 0000000..6a7dba5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_more_vert.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_more_vert_filled.xml b/android/core/design/src/main/res/drawable/ic_more_vert_filled.xml new file mode 100644 index 0000000..0b2c973 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_more_vert_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_movie.xml b/android/core/design/src/main/res/drawable/ic_movie.xml new file mode 100644 index 0000000..9963eeb --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_movie.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_movie_filled.xml b/android/core/design/src/main/res/drawable/ic_movie_filled.xml new file mode 100644 index 0000000..39b8063 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_movie_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_music_note.xml b/android/core/design/src/main/res/drawable/ic_music_note.xml new file mode 100644 index 0000000..7776c89 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_music_note.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_music_note_filled.xml b/android/core/design/src/main/res/drawable/ic_music_note_filled.xml new file mode 100644 index 0000000..099184c --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_music_note_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_notes.xml b/android/core/design/src/main/res/drawable/ic_notes.xml new file mode 100644 index 0000000..cd8070f --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_notes.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_notes_filled.xml b/android/core/design/src/main/res/drawable/ic_notes_filled.xml new file mode 100644 index 0000000..366b07e --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_notes_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_open_in_new.xml b/android/core/design/src/main/res/drawable/ic_open_in_new.xml new file mode 100644 index 0000000..8d8cf20 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_open_in_new.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_open_in_new_filled.xml b/android/core/design/src/main/res/drawable/ic_open_in_new_filled.xml new file mode 100644 index 0000000..2a23d62 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_open_in_new_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_pause.xml b/android/core/design/src/main/res/drawable/ic_pause.xml new file mode 100644 index 0000000..4e5fa93 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_pause.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_pause_filled.xml b/android/core/design/src/main/res/drawable/ic_pause_filled.xml new file mode 100644 index 0000000..7630623 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_pause_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_person.xml b/android/core/design/src/main/res/drawable/ic_person.xml new file mode 100644 index 0000000..2494a34 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_person.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_person_add.xml b/android/core/design/src/main/res/drawable/ic_person_add.xml new file mode 100644 index 0000000..47a3e12 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_person_add.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_person_add_filled.xml b/android/core/design/src/main/res/drawable/ic_person_add_filled.xml new file mode 100644 index 0000000..3b34eac --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_person_add_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_person_filled.xml b/android/core/design/src/main/res/drawable/ic_person_filled.xml new file mode 100644 index 0000000..8aac3c1 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_person_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_pets.xml b/android/core/design/src/main/res/drawable/ic_pets.xml new file mode 100644 index 0000000..e3c74fb --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_pets.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_pets_filled.xml b/android/core/design/src/main/res/drawable/ic_pets_filled.xml new file mode 100644 index 0000000..038f282 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_pets_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_phonelink_erase.xml b/android/core/design/src/main/res/drawable/ic_phonelink_erase.xml new file mode 100644 index 0000000..eae172d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_phonelink_erase.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_phonelink_erase_filled.xml b/android/core/design/src/main/res/drawable/ic_phonelink_erase_filled.xml new file mode 100644 index 0000000..9d44f97 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_phonelink_erase_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_photo_library.xml b/android/core/design/src/main/res/drawable/ic_photo_library.xml new file mode 100644 index 0000000..67b0804 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_photo_library.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_photo_library_filled.xml b/android/core/design/src/main/res/drawable/ic_photo_library_filled.xml new file mode 100644 index 0000000..611ccbc --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_photo_library_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_receipt_long.xml b/android/core/design/src/main/res/drawable/ic_receipt_long.xml new file mode 100644 index 0000000..a675985 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_receipt_long.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_receipt_long_filled.xml b/android/core/design/src/main/res/drawable/ic_receipt_long_filled.xml new file mode 100644 index 0000000..548696c --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_receipt_long_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_refresh.xml b/android/core/design/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..08288d6 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_refresh_filled.xml b/android/core/design/src/main/res/drawable/ic_refresh_filled.xml new file mode 100644 index 0000000..c166708 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_refresh_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_restart_alt.xml b/android/core/design/src/main/res/drawable/ic_restart_alt.xml new file mode 100644 index 0000000..08a0ff1 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_restart_alt.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_restart_alt_filled.xml b/android/core/design/src/main/res/drawable/ic_restart_alt_filled.xml new file mode 100644 index 0000000..920b41d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_restart_alt_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_restaurant.xml b/android/core/design/src/main/res/drawable/ic_restaurant.xml new file mode 100644 index 0000000..a354d5d --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_restaurant.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_restaurant_filled.xml b/android/core/design/src/main/res/drawable/ic_restaurant_filled.xml new file mode 100644 index 0000000..e31aed4 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_restaurant_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_schedule.xml b/android/core/design/src/main/res/drawable/ic_schedule.xml new file mode 100644 index 0000000..80959f3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_schedule.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_schedule_filled.xml b/android/core/design/src/main/res/drawable/ic_schedule_filled.xml new file mode 100644 index 0000000..b93b8fa --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_schedule_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_school.xml b/android/core/design/src/main/res/drawable/ic_school.xml new file mode 100644 index 0000000..b6e8087 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_school.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_school_filled.xml b/android/core/design/src/main/res/drawable/ic_school_filled.xml new file mode 100644 index 0000000..7a03f50 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_school_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_settings.xml b/android/core/design/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..3a2efcb --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_settings_filled.xml b/android/core/design/src/main/res/drawable/ic_settings_filled.xml new file mode 100644 index 0000000..790cf9e --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_settings_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_share.xml b/android/core/design/src/main/res/drawable/ic_share.xml new file mode 100644 index 0000000..8d092cb --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_share.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_share_filled.xml b/android/core/design/src/main/res/drawable/ic_share_filled.xml new file mode 100644 index 0000000..87f97a1 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_share_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_shield.xml b/android/core/design/src/main/res/drawable/ic_shield.xml new file mode 100644 index 0000000..71cbf89 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_shield.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_shield_filled.xml b/android/core/design/src/main/res/drawable/ic_shield_filled.xml new file mode 100644 index 0000000..32250d7 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_shield_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_shopping_bag.xml b/android/core/design/src/main/res/drawable/ic_shopping_bag.xml new file mode 100644 index 0000000..0074f7c --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_shopping_bag.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_shopping_bag_filled.xml b/android/core/design/src/main/res/drawable/ic_shopping_bag_filled.xml new file mode 100644 index 0000000..17c5ecb --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_shopping_bag_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_shopping_cart.xml b/android/core/design/src/main/res/drawable/ic_shopping_cart.xml new file mode 100644 index 0000000..a4163f9 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_shopping_cart.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_shopping_cart_filled.xml b/android/core/design/src/main/res/drawable/ic_shopping_cart_filled.xml new file mode 100644 index 0000000..e11b2cd --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_shopping_cart_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_smartphone.xml b/android/core/design/src/main/res/drawable/ic_smartphone.xml new file mode 100644 index 0000000..2bbae68 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_smartphone.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_smartphone_filled.xml b/android/core/design/src/main/res/drawable/ic_smartphone_filled.xml new file mode 100644 index 0000000..d5bda83 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_smartphone_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_sports_esports.xml b/android/core/design/src/main/res/drawable/ic_sports_esports.xml new file mode 100644 index 0000000..f27af92 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_sports_esports.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_sports_esports_filled.xml b/android/core/design/src/main/res/drawable/ic_sports_esports_filled.xml new file mode 100644 index 0000000..84e6685 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_sports_esports_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_subscriptions.xml b/android/core/design/src/main/res/drawable/ic_subscriptions.xml new file mode 100644 index 0000000..dc8aecd --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_subscriptions.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_subscriptions_filled.xml b/android/core/design/src/main/res/drawable/ic_subscriptions_filled.xml new file mode 100644 index 0000000..398706f --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_subscriptions_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_swap_horiz.xml b/android/core/design/src/main/res/drawable/ic_swap_horiz.xml new file mode 100644 index 0000000..4c58f24 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_swap_horiz.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_swap_horiz_filled.xml b/android/core/design/src/main/res/drawable/ic_swap_horiz_filled.xml new file mode 100644 index 0000000..2559934 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_swap_horiz_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_sync.xml b/android/core/design/src/main/res/drawable/ic_sync.xml new file mode 100644 index 0000000..9dbf2e5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_sync.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_sync_filled.xml b/android/core/design/src/main/res/drawable/ic_sync_filled.xml new file mode 100644 index 0000000..65625de --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_sync_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_sync_problem.xml b/android/core/design/src/main/res/drawable/ic_sync_problem.xml new file mode 100644 index 0000000..fbee1c5 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_sync_problem.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_sync_problem_filled.xml b/android/core/design/src/main/res/drawable/ic_sync_problem_filled.xml new file mode 100644 index 0000000..fdcaa13 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_sync_problem_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_system_update.xml b/android/core/design/src/main/res/drawable/ic_system_update.xml new file mode 100644 index 0000000..2b574e6 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_system_update.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_system_update_filled.xml b/android/core/design/src/main/res/drawable/ic_system_update_filled.xml new file mode 100644 index 0000000..3d62dba --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_system_update_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_train.xml b/android/core/design/src/main/res/drawable/ic_train.xml new file mode 100644 index 0000000..38bcdfd --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_train.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_train_filled.xml b/android/core/design/src/main/res/drawable/ic_train_filled.xml new file mode 100644 index 0000000..a44295e --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_train_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_unarchive.xml b/android/core/design/src/main/res/drawable/ic_unarchive.xml new file mode 100644 index 0000000..42533ad --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_unarchive.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_unarchive_filled.xml b/android/core/design/src/main/res/drawable/ic_unarchive_filled.xml new file mode 100644 index 0000000..6aaacec --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_unarchive_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_warning.xml b/android/core/design/src/main/res/drawable/ic_warning.xml new file mode 100644 index 0000000..29ab293 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_warning.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_warning_filled.xml b/android/core/design/src/main/res/drawable/ic_warning_filled.xml new file mode 100644 index 0000000..4d0654f --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_warning_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_water_drop.xml b/android/core/design/src/main/res/drawable/ic_water_drop.xml new file mode 100644 index 0000000..61b70a3 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_water_drop.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_water_drop_filled.xml b/android/core/design/src/main/res/drawable/ic_water_drop_filled.xml new file mode 100644 index 0000000..cc913d9 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_water_drop_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_wifi.xml b/android/core/design/src/main/res/drawable/ic_wifi.xml new file mode 100644 index 0000000..e6cc326 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_wifi.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/drawable/ic_wifi_filled.xml b/android/core/design/src/main/res/drawable/ic_wifi_filled.xml new file mode 100644 index 0000000..6d63910 --- /dev/null +++ b/android/core/design/src/main/res/drawable/ic_wifi_filled.xml @@ -0,0 +1,13 @@ + + + + + + + diff --git a/android/core/design/src/main/res/values-sv/strings.xml b/android/core/design/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..bb2b8a6 --- /dev/null +++ b/android/core/design/src/main/res/values-sv/strings.xml @@ -0,0 +1,387 @@ + + + + Spara + Avbryt + Ta bort + Ändra + + Stäng + + Tillbaka + Fortsätt + + Namn + + Skriv ett namn + Försök igen + + Fler alternativ + + Du + + du + Idag + Igår + + Kräver anslutning + + Kunde inte nå servern + Offline + + Väntar på synk + + Arkiverat + + arkiverad + Slänga ändringarna? + Släng + Fortsätt redigera + + %1$s kronor + + kr + + %1$s/%2$s + + %1$s %% + + %1$s %2$s + + " · " + %1$s och %2$s + + ", " + + Partner + + Bjud in partner + + Datum: %1$s + Översikt + Statistik + Inställningar + + Navigering + + Lägg till utgift + + %1$s är skyldig dig + Du är skyldig %1$s + + %1$s är skyldig dig %2$s + Du är skyldig %1$s %2$s + + Ni är kvitt + + Kvitt + + Ingen är skyldig något just nu. + Du lånade ut %1$s + Du lånade %1$s + Inte inblandad + Betalade för dig själv + %1$s betalade dig %2$s + Du betalade %1$s %2$s + + + %d konto + %d konton + + + Synkad %1$s + nyss + för %d min sedan + för %d h sedan + + idag %1$s + Synkar… + + + %d ändring väntar på synk + %d ändringar väntar på synk + + + + %d ändring synkades inte + %d ändringar synkades inte + + Synk misslyckades · försöker igen snart + Telefonens klocka går fel + + Synkstatus: %1$s + + Delade utgifter för två + + Inbjudningskod + + Lägg till bild + Byt bild + Ta bort bild + Bilden kunde inte användas + + Bilden är för stor (högst 5 MB) + + Använd en bild i JPEG, PNG eller WebP + + Bilden kunde inte läsas + + Laddar upp bilden… + Utgiften sparades + Sparad. Synkas när du är online. + Betalningen sparades + + %1$s · %2$s + + %1$s %% + + % + Belopp + + 0,00 + Beskrivning + Konto + + Konto: %1$s + Kategori + + Alla + + Alla kategorier + Betalt av + + Betalt av %1$s. Dubbeltryck för att byta till %2$s. + Delning + + Kontots standard %1$s + Egen fördelning + Exakta belopp + Allt på en person + Allt på %1$s + + Vem står för hela beloppet? + + räknas ut + + %1$s %2$s av %3$s + + Hälften var + + Fäll ihop delning + + %1$s %2$s procent, %3$s %4$s procent + + %1$ss andel + + Den här utgiften: %1$s + Anteckningar + Lägg till anteckning + + Radera + + Decimaltecken + + Sifferknappar + + Rensa + + Nästa + Välj konto + Välj kategori + + Skriv ett belopp + + Beloppet måste vara mellan %1$s och %2$s + Skriv en beskrivning + + + Högst %d tecken + Högst %d tecken + + + Datumet ligger för långt fram eller bak + + Andelen är större än beloppet + + Delarna måste bli 100 % + + Det gick inte att spara. Kontrollera fälten. + Utgiften togs bort + Historik + + %1$s skapade den här + %1$s tog bort den här + + %1$s ändrade %2$s från %3$s till %4$s + + %1$s lade till %2$s: %3$s + + %1$s tog bort %2$s + + Skapad automatiskt (återkommande) + + Importerad från Splitwise + + Din ändring väntar på synk + + · %1$s + kontot + kategorin + betalaren + beloppet + beskrivningen + anteckningen + datumet + + delningen + andelarna + + vem som betalade + + vem som fick + + anteckningen + fördelningen + + %1$s för mycket + Betalningen togs bort + + Mat & hushåll + Restaurang & café + + Boende + Hem + Räkningar + + Försäkringar + Transport + Resor + Nöje + Hälsa + + Barn + Shopping + + Avgifter & skatter + Övrigt + + Grön + Orange + Brun + Gul + Blå + Blågrön + Lila + Röd + Rosa + Grå + Indigo + Lime + + Plommon + + Sand + + Kundvagn + Restaurang + Kafé + Hem + El + Vatten + Internet + Bil + Tåg + Bränsle + Flyg + Hotell + Bagage + Film + Musik + Spel + Träning + Vård + Medicin + Shoppingkasse + Kläder + Husdjur + Barn + Skola + Present + Fest + Reparationer + Möbler + Prenumerationer + Övrigt + + Nyckel + + Sköld + + Bank + Version %1$s finns + + Nyheter och rättningar · %1$s + Uppdatera + Senare + Laddar ner… %1$d %% + + Laddar ner version %1$s + + Nedladdningen misslyckades. Försök igen. + Tillåt installation + + Kvitt uppdateras direkt från er server, inte från Play Butik. Därför frågar Android en gång om Kvitt får Installera okända appar. Slå på Tillåt från den här källan och gå sedan tillbaka till Kvitt. Uppdateringen fortsätter av sig själv. + + Din partner har redan gått med + + Du får inte göra det här + + Det här finns inte längre + + Din partner behöver gå med först + + För många försök. Vänta en stund och försök igen. + + Något gick fel på servern. Försök igen senare. + + Uppdatera Kvitt för att fortsätta + + Servern godtog inte värdena + + Aktivitet + %1$s lade till en utgift + + %1$s · %2$s · %3$s + %1$s ändrade en utgift + + %1$s · %2$s + %1$s tog bort en utgift + + %1$s · %2$s + %1$s registrerade en betalning + + %1$s betalade %2$s %3$s + + %1$s betalade dig %2$s + + Du betalade %1$s %2$s + %1$s ändrade en betalning + %1$s tog bort en betalning + + %1$s + Återkommande utgift tillagd + + %1$s · %2$s · %3$s + + + %1$s gjorde %2$d ändring + %1$s gjorde %2$d ändringar + + Öppna Kvitt för att se dem + + %1$s ändrade något i Kvitt + + Kvitt + Öppna Kvitt för att se det + diff --git a/android/core/design/src/main/res/values/strings.xml b/android/core/design/src/main/res/values/strings.xml new file mode 100644 index 0000000..9c7d38e --- /dev/null +++ b/android/core/design/src/main/res/values/strings.xml @@ -0,0 +1,389 @@ + + + + Save + Cancel + Delete + Edit + + Close + + Back + Continue + + Name + + Enter a name + Try again + + More options + + You + + you + Today + Yesterday + + Needs connection + + Could not reach the server + Offline + + Waiting to sync + + Archived + + archived + Discard changes? + Discard + Keep editing + + %1$s Swedish kronor + + SEK + + %1$s/%2$s + + %1$s %% + + %1$s %2$s + + " · " + %1$s and %2$s + + ", " + + Partner + + Invite partner + + Date: %1$s + Home + Analytics + Settings + + Navigation + + Add expense + + %1$s owes you + You owe %1$s + + %1$s owes you %2$s + You owe %1$s %2$s + + All settled up + + Settled up + + Nobody owes anything right now. + You lent %1$s + You borrowed %1$s + Not involved + Paid for yourself + %1$s paid you %2$s + You paid %1$s %2$s + + + %d account + %d accounts + + + Synced %1$s + just now + %d min ago + %d h ago + + today %1$s + Syncing… + + + %d change waiting to sync + %d changes waiting to sync + + + + %d change was not synced + %d changes were not synced + + Sync failed · trying again soon + Your phone\'s clock is wrong + + Sync status: %1$s + + Kvitt + + Shared expenses for two + + Invite code + + Add picture + Change picture + Remove picture + The picture could not be used + + The picture is too large (max 5 MB) + + Use a JPEG, PNG or WebP picture + + The picture could not be read + + Uploading picture… + Expense saved + Saved. Syncs when you\'re online. + Payment saved + + %1$s · %2$s + + %1$s %% + + % + Amount + + 0,00 + Description + Account + + Account: %1$s + Category + + All + + All categories + Paid by + + Paid by %1$s. Double-tap to switch to %2$s. + Split + + Account default %1$s + Custom ratio + Exact amounts + All on one person + All on %1$s + + Who pays the whole amount? + + calculated + + %1$s %2$s of %3$s + + Half each + + Collapse split + + %1$s %2$s percent, %3$s %4$s percent + + %1$s\'s share + + This expense: %1$s + Notes + Add note + + Delete + + Decimal separator + + Number keys + + Clear + + Next + Choose account + Choose category + + Enter an amount + + The amount must be between %1$s and %2$s + Enter a description + + + At most %d character + At most %d characters + + + The date is too far ahead or back + + The share is larger than the amount + + The parts must add up to 100 % + + This could not be saved. Check the fields. + Expense deleted + History + + %1$s created this + %1$s deleted this + + %1$s changed %2$s from %3$s to %4$s + + %1$s added %2$s: %3$s + + %1$s removed %2$s + + Created automatically (recurring) + + Imported from Splitwise + + Your change is waiting to sync + + · %1$s + the account + the category + the payer + the amount + the description + the notes + the date + + the split + the shares + + who paid + + who received + + the note + the allocation + + %1$s too much + Payment deleted + + Groceries + Restaurants + + Housing + Home + Utilities + + Insurance + Transport + Travel + Entertainment + Health + + Children + Shopping + + Fees & taxes + Other + + Green + Orange + Brown + Yellow + Blue + Teal + Purple + Red + Pink + Grey + Indigo + Lime + + Plum + + Sand + + Shopping cart + Restaurant + Café + Home + Electricity + Water + Internet + Car + Train + Fuel + Flight + Hotel + Luggage + Film + Music + Games + Fitness + Health care + Medicine + Shopping bag + Clothes + Pets + Children + School + Gift + Party + Repairs + Furniture + Subscriptions + Other + + Key + + Shield + + Bank + Version %1$s available + + New features and fixes · %1$s + Update + Later + Downloading… %1$d %% + + Downloading version %1$s + + Download failed, try again + Allow installation + + Kvitt updates straight from your own server, not from the Play Store, so Android asks once whether Kvitt may Install unknown apps. Turn on Allow from this source and come back to Kvitt. The update continues by itself. + + Your partner has already joined + + You are not allowed to do this + + This no longer exists + + Your partner needs to join first + + Too many attempts. Wait a while and try again. + + Something went wrong on the server. Try again later. + + Update Kvitt to continue + + The server did not accept the values + + Activity + %1$s added an expense + + %1$s · %2$s · %3$s + %1$s changed an expense + + %1$s · %2$s + %1$s deleted an expense + + %1$s · %2$s + %1$s recorded a payment + + %1$s paid %2$s %3$s + + %1$s paid you %2$s + + You paid %1$s %2$s + %1$s changed a payment + %1$s deleted a payment + + %1$s + Recurring expense added + + %1$s · %2$s · %3$s + + + %1$s made %2$d change + %1$s made %2$d changes + + Open Kvitt to see them + + %1$s changed something in Kvitt + + Kvitt + Open Kvitt to see it + diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/StringsCatalogueTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/StringsCatalogueTest.kt new file mode 100644 index 0000000..2177155 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/StringsCatalogueTest.kt @@ -0,0 +1,93 @@ +package se.hagstedt.kvitt.design + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.w3c.dom.Element +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory + +/** + * The shared strings of `:core:design` are exactly the rows of docs/ux/strings.csv whose key has no feature prefix + * (D-017, SPEC §14.7 Strings), with the CSV's English and Swedish text, plurals included. + */ +class StringsCatalogueTest { + private val features = listOf("onboarding_", "home_", "account_", "expense_", "settle_", "analytics_", "settings_") + + private data class Row(val key: String, val en: String, val sv: String, val notes: String) + + private fun csv(): List { + val text = File("../../../docs/ux/strings.csv").readText() + val rows = mutableListOf>() + var field = StringBuilder() + var record = mutableListOf() + var quoted = false + var i = 0 + while (i < text.length) { + val c = text[i] + when { + quoted && c == '"' && i + 1 < text.length && text[i + 1] == '"' -> { field.append('"'); i++ } + c == '"' -> quoted = !quoted + !quoted && c == ',' -> { record.add(field.toString()); field = StringBuilder() } + !quoted && c == '\n' -> { record.add(field.toString()); rows.add(record); record = mutableListOf(); field = StringBuilder() } + c == '\r' -> Unit + else -> field.append(c) + } + i++ + } + if (field.isNotEmpty() || record.isNotEmpty()) { record.add(field.toString()); rows.add(record) } + return rows.drop(1).filter { it.size >= 4 }.map { Row(it[0], it[1], it[2], it[3]) } + .filter { row -> features.none { row.key.startsWith(it) } && row.key != "app_name" } + } + + private fun unescape(raw: String): String { + val t = raw.trim().let { if (it.length >= 2 && it.startsWith('"') && it.endsWith('"')) it.substring(1, it.length - 1) else it } + return t.replace("\\'", "'").replace("\\\"", "\"").replace("\\@", "@").replace("\\\\", "\\") + } + + private fun resources(folder: String): Map { + val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File("src/main/res/$folder/strings.xml")) + val out = mutableMapOf() + val strings = doc.getElementsByTagName("string") + for (i in 0 until strings.length) { + val e = strings.item(i) as Element + out[e.getAttribute("name")] = unescape(e.textContent) + } + val plurals = doc.getElementsByTagName("plurals") + for (i in 0 until plurals.length) { + val p = plurals.item(i) as Element + val items = p.getElementsByTagName("item") + for (j in 0 until items.length) { + val item = items.item(j) as Element + out[p.getAttribute("name") + ":" + item.getAttribute("quantity")] = unescape(item.textContent) + } + } + return out + } + + @Test + fun englishMatchesTheCsv() { + val rows = csv() + val en = resources("values") + assertEquals(rows.map { it.key }.toSet(), en.keys) + rows.forEach { assertEquals(it.key, it.en, en[it.key]) } + } + + @Test + fun swedishMatchesTheCsv() { + val rows = csv().filter { "translatable=false" !in it.notes } + val sv = resources("values-sv") + assertEquals(rows.map { it.key }.toSet(), sv.keys) + rows.forEach { assertEquals(it.key, it.sv, sv[it.key]) } + } + + @Test + fun theFourteenDefaultCategoryNamesAndTheNotificationCopyArePresent() { + val en = resources("values") + assertEquals(14, en.keys.count { it.startsWith("category_default_") }) + assertEquals(33, en.keys.count { it.startsWith("icon_") }) + assertEquals(14, en.keys.count { it.startsWith("color_") }) + assertTrue(en.keys.count { it.startsWith("notif_") } >= 20) + assertEquals("Aktivitet", resources("values-sv")["notif_channel_activity"]) + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ComponentLogicTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ComponentLogicTest.kt new file mode 100644 index 0000000..d615f6b --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ComponentLogicTest.kt @@ -0,0 +1,102 @@ +package se.hagstedt.kvitt.design.component + +import org.junit.Assert.assertEquals +import org.junit.Test +import se.hagstedt.kvitt.design.chart.niceStep +import se.hagstedt.kvitt.design.icon.CategoryIconRes +import se.hagstedt.kvitt.model.Category +import se.hagstedt.kvitt.model.CategoryIcons +import se.hagstedt.kvitt.model.CategoryId +import se.hagstedt.kvitt.model.ColorToken +import se.hagstedt.kvitt.model.DefaultCategoryKey +import se.hagstedt.kvitt.model.SyncSummary +import se.hagstedt.kvitt.model.UserId + +/** The pure rules inside the components: slots, initials, the quick row, invite codes, the sync pill, chart steps. */ +class ComponentLogicTest { + private val jacob = UserId("01920000-0000-7000-8000-0000000000a1") + private val anna = UserId("01920000-0000-7000-8000-0000000000a2") + + @Test + fun avatarSlotIsTheLowestIdFirstOnBothPhones() { + assertEquals(1, AvatarSlots.slotOf(jacob, listOf(jacob, anna))) + assertEquals(2, AvatarSlots.slotOf(anna, listOf(jacob, anna))) + assertEquals(2, AvatarSlots.slotOf(anna, listOf(anna, jacob))) + assertEquals(1, AvatarSlots.slotOf(UserId("01920000-0000-7000-8000-0000000000A1".lowercase()), listOf(UserId("01920000-0000-7000-8000-0000000000A2")))) + assertEquals(1, AvatarSlots.slotOf(anna, listOf(anna))) + } + + @Test + fun initialsAreTheFirstLettersOfTheFirstTwoWords() { + assertEquals("A", initialsOf("Anna")) + assertEquals("AL", initialsOf("anna-karin lindqvist berg")) + assertEquals("ÖÅ", initialsOf("örjan åberg")) + assertEquals("", initialsOf(" ")) + } + + @Test + fun firstGraphemeKeepsSurrogatePairs() { + assertEquals("V", firstGrapheme("Vardag").uppercase()) + assertEquals("🏠", firstGrapheme("🏠 Hemma")) + assertEquals("Ö", firstGrapheme("Ölandsresan")) + } + + private fun cat(n: Int, sort: Int, archived: Boolean = false) = + Category(CategoryId("c$n"), DefaultCategoryKey.entries[n], null, "category", ColorToken.GREY, sort, archived) + + @Test + fun quickRowFallsBackToSelectedThenSortOrder() { + val active = (0 until 8).map { cat(it, it * 10) } + assertEquals(listOf("c5", "c0", "c1", "c2", "c3"), quickRowCategories(active, emptyList(), CategoryId("c5")).map { it.id.value }) + assertEquals(listOf("c0", "c1", "c2", "c3", "c4"), quickRowCategories(active, emptyList(), null).map { it.id.value }) + } + + @Test + fun quickRowUsesFrequentAndPutsTheSelectedFifth() { + val active = (0 until 8).map { cat(it, it * 10) } + val frequent = listOf("c6", "c2", "c4").map(::CategoryId) + assertEquals(listOf("c6", "c2", "c4", "c0", "c1"), quickRowCategories(active, frequent, CategoryId("c2")).map { it.id.value }) + assertEquals(listOf("c6", "c2", "c4", "c0", "c7"), quickRowCategories(active, frequent, CategoryId("c7")).map { it.id.value }) + } + + @Test + fun quickRowSkipsArchived() { + val active = listOf(cat(0, 0, archived = true)) + (1 until 8).map { cat(it, it * 10) } + assertEquals(listOf("c1", "c2", "c3", "c4", "c5"), quickRowCategories(active, emptyList(), null).map { it.id.value }) + } + + @Test + fun inviteCodesAreNormalisedPerSpec83() { + assertEquals("7K3MQX9D2HPA", normalizeInviteCode("7k3m-qx9d 2hpa")) + assertEquals("010000000000", normalizeInviteCode("oI" + "O".repeat(10))) + assertEquals("11", normalizeInviteCode("il")) + assertEquals("7K3M-QX9D-2HPA", groupInviteCode("7K3MQX9D2HPA")) + assertEquals("7K3M-QX", groupInviteCode("7K3MQX")) + assertEquals(12, normalizeInviteCode("7K3MQX9D2HPAEXTRA").length) + } + + @Test + fun syncPillVariants() { + val synced = SyncSummary("2026-10-17T11:59:00.000Z", 0, 0, clockSkew = false, running = false) + assertEquals(SyncPillVariant.Synced, syncPillVariant(synced, online = true)) + assertEquals(SyncPillVariant.Running, syncPillVariant(synced.copy(running = true), online = true)) + assertEquals(SyncPillVariant.Offline, syncPillVariant(synced.copy(pendingCount = 2), online = false)) + assertEquals(SyncPillVariant.Rejected, syncPillVariant(synced.copy(rejectedCount = 1), online = false)) + assertEquals(SyncPillVariant.ClockWrong, syncPillVariant(synced.copy(clockSkew = true, rejectedCount = 1), online = true)) + assertEquals(SyncPillVariant.Failed, syncPillVariant(synced, online = true, failed = true)) + } + + @Test + fun chartGridlinesAreRound() { + assertEquals(15_000L, niceStep(41_484)) + assertEquals(2_500L, niceStep(6_200)) + assertEquals(1L, niceStep(0)) + assertEquals(200_000L, niceStep(600_000)) + } + + @Test + fun everyCategoryIconTokenHasADrawable() { + CategoryIcons.ALL.forEach { assertEquals(it, true, CategoryIconRes.isKnown(it)) } + assertEquals(33, CategoryIcons.ALL.size) + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/AmountInputTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/AmountInputTest.kt new file mode 100644 index 0000000..f78ed41 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/AmountInputTest.kt @@ -0,0 +1,67 @@ +package se.hagstedt.kvitt.design.format + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import se.hagstedt.kvitt.design.format.KvittLanguage.ENGLISH +import se.hagstedt.kvitt.design.format.KvittLanguage.SWEDISH + +/** The keypad's input rules (SPEC §9.6, design-spec §3.7). */ +class AmountInputTest { + private val nb = " " + + private fun type(vararg keys: AmountKey): AmountInput? = + keys.fold(AmountInput() as AmountInput?) { input, key -> input?.press(key) } + + private fun digits(text: String): Array = + text.map { if (it == ',') AmountKey.Decimal else AmountKey.Digit(it.digitToInt()) }.toTypedArray() + + @Test + fun typesAndGroups() { + val input = type(*digits("1060,5")) + assertEquals("1${nb}060,5", input?.display(SWEDISH)) + assertEquals("1,060.5", input?.display(ENGLISH)) + assertEquals(106_050L, input?.ore) + assertEquals(41_200L, type(*digits("412"))?.ore) + } + + @Test + fun decimalFirstGivesZeroComma() { + assertEquals("0,", type(AmountKey.Decimal)?.display(SWEDISH)) + assertEquals(50L, type(*digits(",5"))?.ore) + } + + @Test + fun aSecondDecimalSeparatorAndAThirdDecimalAreIgnored() { + assertNull(type(*digits("12,"), AmountKey.Decimal)) + assertNull(type(*digits("12,345"))) + assertEquals(1_234L, type(*digits("12,34"))?.ore) + } + + @Test + fun leadingZeroIsReplaced() { + assertEquals("7", type(*digits("07"))?.raw) + assertEquals("0,7", type(*digits("0,7"))?.display(SWEDISH)) + } + + @Test + fun aboveTheMaximumIsRejected() { + assertEquals(1_000_000_000L, type(*digits("10000000"))?.ore) + assertNull(type(*digits("100000000"))) + assertNull(type(*digits("10000000,01"))) + } + + @Test + fun deleteAndClear() { + assertEquals("41", type(*digits("412"), AmountKey.Delete)?.raw) + assertEquals(true, type(*digits("412"), AmountKey.Clear)?.isEmpty) + assertNull(AmountInput().ore) + } + + @Test + fun restoresStoredAmounts() { + assertEquals("1247.6", AmountInput.of(124_760).raw) + assertEquals("412", AmountInput.of(41_200).raw) + assertEquals("0.05", AmountInput.of(5).raw) + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/DateFormatterTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/DateFormatterTest.kt new file mode 100644 index 0000000..ab3ec05 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/DateFormatterTest.kt @@ -0,0 +1,109 @@ +package se.hagstedt.kvitt.design.format + +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import se.hagstedt.kvitt.design.format.KvittLanguage.ENGLISH +import se.hagstedt.kvitt.design.format.KvittLanguage.SWEDISH +import kotlin.time.Instant + +/** Every date example of SPEC §9.8 and the design spec, both languages, current and other year, relative times. */ +class DateFormatterTest { + private val stockholm = TimeZone.of("Europe/Stockholm") + private val today = LocalDate(2026, 10, 17) + + @Test + fun listDatesWithoutYearInTheCurrentYear() { + assertEquals("3 okt.", DateFormatter.listDate(LocalDate(2026, 10, 3), today, SWEDISH)) + assertEquals("3 Oct", DateFormatter.listDate(LocalDate(2026, 10, 3), today, ENGLISH)) + assertEquals("16 okt.", DateFormatter.listDate("2026-10-16", "2026-10-17", SWEDISH)) + assertEquals("25 sep.", DateFormatter.listDate(LocalDate(2026, 9, 25), today, SWEDISH)) + assertEquals("7 maj", DateFormatter.listDate(LocalDate(2026, 5, 7), today, SWEDISH)) + } + + @Test + fun listDatesWithYearInOtherYears() { + assertEquals("3 okt. 2025", DateFormatter.listDate(LocalDate(2025, 10, 3), today, SWEDISH)) + assertEquals("3 Oct 2025", DateFormatter.listDate(LocalDate(2025, 10, 3), today, ENGLISH)) + assertEquals("1 jan. 2027", DateFormatter.listDate(LocalDate(2027, 1, 1), today, SWEDISH)) + } + + @Test + fun spokenDates() { + assertEquals("16 oktober", DateFormatter.spokenDate(LocalDate(2026, 10, 16), today, SWEDISH)) + assertEquals("16 October 2025", DateFormatter.spokenDate(LocalDate(2025, 10, 16), today, ENGLISH)) + } + + @Test + fun detailDates() { + assertEquals("lördag 3 oktober 2026", DateFormatter.detailDate(LocalDate(2026, 10, 3), SWEDISH)) + assertEquals("Saturday, 3 October 2026", DateFormatter.detailDate(LocalDate(2026, 10, 3), ENGLISH)) + assertEquals("fredag 16 oktober 2026", DateFormatter.detailDate("2026-10-16", SWEDISH)) + assertEquals("tisdag 6 oktober 2026", DateFormatter.detailDate("2026-10-06", SWEDISH)) + } + + @Test + fun timesAre24HourInTheLocalZone() { + assertEquals("14:02", DateFormatter.time(Instant.parse("2026-10-16T12:02:00.000Z"), stockholm)) + assertEquals("06:00", DateFormatter.time(Instant.parse("2026-09-25T04:00:00Z"), stockholm)) + assertEquals("16 okt. 19:40", DateFormatter.listDateTime("2026-10-16T17:40:00.000Z", stockholm, "2026-10-17", SWEDISH)) + assertEquals("16 Oct 19:40", DateFormatter.listDateTime("2026-10-16T17:40:00.000Z", stockholm, "2026-10-17", ENGLISH)) + // Winter time: UTC+1. + assertEquals("09:12", DateFormatter.time(Instant.parse("2026-11-17T08:12:00Z"), stockholm)) + } + + @Test + fun weekdayDateTime() { + assertEquals("mån 19 okt. 21:40", DateFormatter.weekdayDateTime(Instant.parse("2026-10-19T19:40:00Z"), stockholm, today, SWEDISH)) + assertEquals("Tue 20 Oct 14:02", DateFormatter.weekdayDateTime(Instant.parse("2026-10-20T12:02:00Z"), stockholm, today, ENGLISH)) + } + + @Test + fun relativeLastSynced() { + val now = Instant.parse("2026-10-17T12:00:00Z") + assertEquals(RelativeTime.JustNow, DateFormatter.relative(Instant.parse("2026-10-17T11:59:01Z"), now, stockholm, SWEDISH)) + assertEquals(RelativeTime.MinutesAgo(1), DateFormatter.relative(Instant.parse("2026-10-17T11:59:00Z"), now, stockholm, SWEDISH)) + assertEquals(RelativeTime.MinutesAgo(3), DateFormatter.relative(Instant.parse("2026-10-17T11:57:00Z"), now, stockholm, SWEDISH)) + assertEquals(RelativeTime.MinutesAgo(59), DateFormatter.relative(Instant.parse("2026-10-17T11:00:01Z"), now, stockholm, SWEDISH)) + assertEquals(RelativeTime.HoursAgo(1), DateFormatter.relative(Instant.parse("2026-10-17T11:00:00Z"), now, stockholm, SWEDISH)) + assertEquals(RelativeTime.HoursAgo(23), DateFormatter.relative(Instant.parse("2026-10-16T12:00:01Z"), now, stockholm, SWEDISH)) + assertEquals(RelativeTime.At("16 okt. 14:00"), DateFormatter.relative(Instant.parse("2026-10-16T12:00:00Z"), now, stockholm, SWEDISH)) + assertEquals(RelativeTime.At("16 Oct 14:00"), DateFormatter.relative(Instant.parse("2026-10-16T12:00:00Z"), now, stockholm, ENGLISH)) + } + + @Test + fun monthHeadersAndCaptions() { + assertEquals("Oktober", DateFormatter.monthHeader("2026-10", today, SWEDISH)) + assertEquals("September 2025", DateFormatter.monthHeader("2025-09", today, SWEDISH)) + assertEquals("October", DateFormatter.monthHeader("2026-10", today, ENGLISH)) + assertEquals("oktober 2026", DateFormatter.monthYear("2026-10", SWEDISH)) + assertEquals("maj–okt 2026", DateFormatter.monthRange(LocalDate(2026, 5, 1), LocalDate(2026, 10, 31), SWEDISH)) + assertEquals("May–Oct 2026", DateFormatter.monthRange(LocalDate(2026, 5, 1), LocalDate(2026, 10, 31), ENGLISH)) + assertEquals("nov 2025–feb 2026", DateFormatter.monthRange(LocalDate(2025, 11, 1), LocalDate(2026, 2, 28), SWEDISH)) + assertEquals("1–30 nov.", DateFormatter.dayRange(LocalDate(2026, 11, 1), LocalDate(2026, 11, 30), today, SWEDISH)) + assertEquals("24 aug.–17 okt.", DateFormatter.dayRange(LocalDate(2026, 8, 24), LocalDate(2026, 10, 17), today, SWEDISH)) + } + + @Test + fun isoWeeks() { + assertEquals(41, DateFormatter.isoWeek(LocalDate(2026, 10, 5))) + assertEquals(42, DateFormatter.isoWeek(LocalDate(2026, 10, 17))) + assertEquals(53, DateFormatter.isoWeek(LocalDate(2026, 12, 28))) + assertEquals(53, DateFormatter.isoWeek(LocalDate(2027, 1, 3))) + assertEquals("2026-W53", DateFormatter.isoWeekKey(LocalDate(2027, 1, 1))) + assertEquals("2025-W01", DateFormatter.isoWeekKey(LocalDate(2024, 12, 30))) + assertEquals("2024-W52", DateFormatter.isoWeekKey(LocalDate(2024, 12, 29))) + assertEquals(1, DateFormatter.isoWeek(LocalDate(2026, 1, 1))) + } + + @Test + fun bucketLabels() { + assertEquals("okt", DateFormatter.bucketLabel("2026-10", SWEDISH)) + assertEquals("Oct", DateFormatter.bucketLabel("2026-10", ENGLISH)) + assertEquals("41", DateFormatter.bucketLabel("2026-W41", SWEDISH)) + assertEquals(41, DateFormatter.weekOfKey("2026-W41")) + assertNull(DateFormatter.weekOfKey("2026-10")) + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/MoneyFormatterTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/MoneyFormatterTest.kt new file mode 100644 index 0000000..4048618 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/format/MoneyFormatterTest.kt @@ -0,0 +1,78 @@ +package se.hagstedt.kvitt.design.format + +import org.junit.Assert.assertEquals +import org.junit.Test +import se.hagstedt.kvitt.design.format.KvittLanguage.ENGLISH +import se.hagstedt.kvitt.design.format.KvittLanguage.SWEDISH + +/** Every money example of SPEC §9.8 and design-spec §3, both languages. */ +class MoneyFormatterTest { + private val nb = " " + + @Test + fun swedishUsesNoBreakSpacesAndCommaDecimal() { + assertEquals("1${nb}234,50${nb}kr", MoneyFormatter.format(123_450, SWEDISH)) + assertEquals("3${nb}250,00${nb}kr", MoneyFormatter.format(325_000, SWEDISH)) + assertEquals("0,00${nb}kr", MoneyFormatter.format(0, SWEDISH)) + assertEquals("189,90${nb}kr", MoneyFormatter.format(18_990, SWEDISH)) + assertEquals("1${nb}234${nb}567,89${nb}kr", MoneyFormatter.format(123_456_789, SWEDISH)) + assertEquals("10${nb}000${nb}000,00${nb}kr", MoneyFormatter.format(1_000_000_000, SWEDISH)) + } + + @Test + fun otherLocalesPutSekFirstWithCommaThousands() { + assertEquals("SEK${nb}1,234.50", MoneyFormatter.format(123_450, ENGLISH)) + assertEquals("SEK${nb}3,250.00", MoneyFormatter.format(325_000, ENGLISH)) + assertEquals("SEK${nb}0.05", MoneyFormatter.format(5, ENGLISH)) + } + + @Test + fun neverShowsASign() { + assertEquals("1${nb}234,50${nb}kr", MoneyFormatter.format(-123_450, SWEDISH)) + assertEquals("SEK${nb}0.05", MoneyFormatter.format(-5, ENGLISH)) + assertEquals("92${nb}233${nb}720${nb}368${nb}547${nb}758,08${nb}kr", MoneyFormatter.format(Long.MIN_VALUE, SWEDISH)) + } + + @Test + fun partsKeepTheUnitApart() { + assertEquals(MoneyParts("3${nb}250,00", "kr", unitFirst = false), MoneyFormatter.parts(325_000, SWEDISH)) + assertEquals(MoneyParts("3,250.00", "SEK", unitFirst = true), MoneyFormatter.parts(325_000, ENGLISH)) + } + + @Test + fun axisLabelsAreWholeKronor() { + assertEquals("15${nb}000", MoneyFormatter.wholeKronor(15_000, SWEDISH)) + assertEquals("15,000", MoneyFormatter.wholeKronor(15_000, ENGLISH)) + assertEquals("0", MoneyFormatter.wholeKronor(0, SWEDISH)) + } + + @Test + fun percentIsHalfUpToOneDecimal() { + assertEquals("34,8", MoneyFormatter.percent(62_000_00, 178_211_40, SWEDISH)) + assertEquals("20.6", MoneyFormatter.percent(36_730_00, 178_211_40, ENGLISH)) + assertEquals("0,1", MoneyFormatter.percent(1, 2_000, SWEDISH)) // 0.05 % rounds up + assertEquals("100,0", MoneyFormatter.percent(5, 5, SWEDISH)) + assertEquals("0,0", MoneyFormatter.percent(0, 0, SWEDISH)) + } + + @Test + fun basisPointsShowDecimalsOnlyWhenNeeded() { + assertEquals("60", MoneyFormatter.basisPoints(6000, SWEDISH)) + assertEquals("33,33", MoneyFormatter.basisPoints(3333, SWEDISH)) + assertEquals("12.5", MoneyFormatter.basisPoints(1250, ENGLISH)) + } + + @Test + fun megabytesHaveOneDecimal() { + assertEquals("8,4${nb}MB", MoneyFormatter.megabytes(8_400_000, SWEDISH)) + assertEquals("8.4${nb}MB", MoneyFormatter.megabytes(8_350_000, ENGLISH)) + } + + @Test + fun languageFromTag() { + assertEquals(SWEDISH, KvittLanguage.of("sv")) + assertEquals(SWEDISH, KvittLanguage.of("sv-SE")) + assertEquals(ENGLISH, KvittLanguage.of("en-GB")) + assertEquals(ENGLISH, KvittLanguage.of("de")) + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/ComponentScreenshotTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/ComponentScreenshotTest.kt new file mode 100644 index 0000000..f20da90 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/ComponentScreenshotTest.kt @@ -0,0 +1,36 @@ +package se.hagstedt.kvitt.design.screenshot + +import androidx.compose.ui.test.junit4.v2.createComposeRule +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.catalogue.CatalogueSheets +import se.hagstedt.kvitt.design.catalogue.LargeFontSheets + +/** + * One golden per component sheet of the catalogue × light/dark × Swedish/English, plus 200 % font scale for the + * sheets in [LargeFontSheets] (design-spec §3.1, PLAN-M6 §5). `check` verifies them; re-record an intended change + * with `tools/gradle-serial.sh :core:design:recordRoborazziDebug` and open every changed PNG. + */ +@RunWith(ParameterizedRobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36], qualifiers = "w411dp-h2400dp-xxhdpi") +class ComponentScreenshotTest(private val sheet: String) { + @get:Rule + val compose = createComposeRule() + + @Test + fun component() { + val content = CatalogueSheets.first { it.first == sheet }.second + Snapshots.snap(compose, sheet, largeFont = sheet in LargeFontSheets, content = content) + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun sheets(): List> = CatalogueSheets.map { arrayOf(it.first) } + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/Snapshots.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/Snapshots.kt new file mode 100644 index 0000000..b02fb65 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/screenshot/Snapshots.kt @@ -0,0 +1,94 @@ +package se.hagstedt.kvitt.design.screenshot + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +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.LocalResources +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import com.github.takahirom.roborazzi.captureRoboImage +import kotlinx.datetime.TimeZone +import se.hagstedt.kvitt.design.KvittTheme +import se.hagstedt.kvitt.design.catalogue.Samples +import se.hagstedt.kvitt.design.component.KvittClock +import se.hagstedt.kvitt.design.component.LocalKvittClock +import se.hagstedt.kvitt.design.component.ProvideHousehold +import java.util.Locale +import kotlin.time.Instant + +/** + * Captures one golden per theme and language: `src/test/screenshots/__.png`, plus + * `_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish and light, like the spec's 200 % frames). + * The content is set once; language (configuration, resources), theme and font scale (the platform's non-linear + * scaling, from a configuration context) switch between captures. Width: the Galaxy S26, 411 dp (SPEC §13.1). The + * clock is fixed at 17 October 2026 14:02 in Stockholm and the viewer is Jacob, so every render is reproducible. + */ +object Snapshots { + 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 fontScale: Float) { + val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else "" + } + + fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable ColumnScope.() -> Unit) { + val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } + + if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList() + 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"))) + fontScale = variant.fontScale + 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) + } + CompositionLocalProvider( + LocalContext provides context, + LocalConfiguration provides context.resources.configuration, + LocalResources provides context.resources, + LocalDensity provides Density(context), + LocalKvittClock provides clock, + ) { + KvittTheme(darkTheme = variant.dark) { + ProvideHousehold(Samples.jacob, Samples.anna) { + Surface(color = MaterialTheme.colorScheme.surface) { + Column( + modifier = Modifier.width(411.dp).wrapContentHeight().padding(KvittTheme.spacing.gutter), + verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap), + content = content, + ) + } + } + } + } + } + variants.forEach { variant -> + current = variant + compose.waitForIdle() + compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png") + } + } +} diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/theme/ContrastTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/theme/ContrastTest.kt new file mode 100644 index 0000000..ace78e0 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/theme/ContrastTest.kt @@ -0,0 +1,121 @@ +package se.hagstedt.kvitt.design.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertTrue +import org.junit.Test +import se.hagstedt.kvitt.model.ColorToken + +/** + * WCAG 2 contrast of every text and graphics pair docs/ux/design-spec.md §2.1 to §2.4 lists, light and dark: text + * ≥ 4.5:1, icons and graphics ≥ 3:1 (SPEC §13.1). Ratios computed from the token values, so a token edit that breaks a + * pair fails here. + */ +class ContrastTest { + private fun ratio(a: Color, b: Color): Double { + val la = a.compositeOver(b).luminance() + 0.05 + val lb = b.luminance() + 0.05 + return maxOf(la, lb) / minOf(la, lb) + } + + private val failures = mutableListOf() + + private fun check(name: String, fg: Color, bg: Color, min: Double) { + val r = ratio(fg, bg) + if (r < min - 0.005) failures += "$name: %.2f < %.1f".format(r, min) + } + + private fun schemes() = listOf("light" to Triple(KvittLightColorScheme, KvittLightColors, false), "dark" to Triple(KvittDarkColorScheme, KvittDarkColors, true)) + + @Test + fun materialRolesMeetTextContrast() { + for ((mode, t) in schemes()) { + val (s, k, _) = t + check("$mode primary/surface", s.primary, s.surface, 4.5) + check("$mode primary/card", s.primary, k.card, 4.5) + check("$mode onPrimary/primary", s.onPrimary, s.primary, 4.5) + check("$mode onPrimaryContainer", s.onPrimaryContainer, s.primaryContainer, 4.5) + check("$mode secondary/surface", s.secondary, s.surface, 4.5) + check("$mode onSecondary", s.onSecondary, s.secondary, 4.5) + check("$mode onSecondaryContainer", s.onSecondaryContainer, s.secondaryContainer, 4.5) + check("$mode tertiary/surface", s.tertiary, s.surface, 4.5) + check("$mode onTertiary", s.onTertiary, s.tertiary, 4.5) + check("$mode onTertiaryContainer", s.onTertiaryContainer, s.tertiaryContainer, 4.5) + check("$mode error/surface", s.error, s.surface, 4.5) + check("$mode error/card", s.error, k.card, 4.5) + check("$mode onError", s.onError, s.error, 4.5) + check("$mode onErrorContainer", s.onErrorContainer, s.errorContainer, 4.5) + check("$mode onSurface", s.onSurface, s.surface, 4.5) + check("$mode onSurfaceVariant/surface", s.onSurfaceVariant, s.surface, 4.5) + check("$mode onSurfaceVariant/card", s.onSurfaceVariant, k.card, 4.5) + check("$mode onSurfaceVariant/containerHighest", s.onSurfaceVariant, s.surfaceContainerHighest, 4.5) + check("$mode inverseOnSurface", s.inverseOnSurface, s.inverseSurface, 4.5) + check("$mode inversePrimary", s.inversePrimary, s.inverseSurface, 4.5) + check("$mode outline (graphics)", s.outline, s.surface, 3.0) + } + assertTrue(failures.joinToString("\n"), failures.isEmpty()) + } + + @Test + fun kvittRolesMeetTextContrast() { + for ((mode, t) in schemes()) { + val (s, k, _) = t + for ((name, fg) in listOf("owed" to k.owed, "owing" to k.owing, "pending" to k.pending)) { + check("$mode $name/surface", fg, s.surface, 4.5) + check("$mode $name/card", fg, k.card, 4.5) + } + check("$mode owed/containerHigh", k.owed, s.surfaceContainerHigh, 4.5) + check("$mode owing/containerHigh", k.owing, s.surfaceContainerHigh, 4.5) + check("$mode settled/surface", k.settled, s.surface, 4.5) + check("$mode settled/settledContainer", k.settled, k.settledContainer, 4.5) + check("$mode warning/surface", k.warning, s.surface, 4.5) + check("$mode onOwed", k.onOwed, k.owed, 4.5) + check("$mode onOwedContainer", k.onOwedContainer, k.owedContainer, 4.5) + check("$mode onOwing", k.onOwing, k.owing, 4.5) + check("$mode onOwingContainer", k.onOwingContainer, k.owingContainer, 4.5) + check("$mode onSettledContainer", k.onSettledContainer, k.settledContainer, 4.5) + check("$mode onWarning", k.onWarning, k.warning, 4.5) + check("$mode onWarningContainer", k.onWarningContainer, k.warningContainer, 4.5) + check("$mode onAvatar1", k.onAvatar(1), k.avatar(1), 4.5) + check("$mode onAvatar2", k.onAvatar(2), k.avatar(2), 4.5) + check("$mode onSurface/syncPillPending", s.onSurface, k.syncPillPending, 4.5) + check("$mode pending/pendingBadge", k.pending, k.pendingBadge, 4.5) + check("$mode chartAxisLabel/card", k.chartAxisLabel, k.card, 4.5) + } + assertTrue(failures.joinToString("\n"), failures.isEmpty()) + } + + @Test + fun categoryAndChartColoursMeetGraphicsContrast() { + for ((mode, t) in schemes()) { + val (s, k, _) = t + for (token in ColorToken.entries) { + check("$mode $token/surface", k.category(token), s.surface, 3.0) + check("$mode $token/card", k.category(token), k.card, 3.0) + check("$mode $token/container", k.category(token), k.categoryContainer(token), 3.0) + check("$mode onCat/$token", k.onCat, k.category(token), 3.0) + } + for (i in 0 until 8) { + check("$mode chart-cat-${i + 1}/card", k.account(i), k.card, 3.0) + check("$mode onCat/chart-cat-${i + 1} (text)", k.onCat, k.account(i), 4.5) + } + } + assertTrue(failures.joinToString("\n"), failures.isEmpty()) + } + + @Test + fun categoryContainersAreTheSpecMixOverSurface() { + for ((mode, t) in schemes()) { + val (s, k, dark) = t + val fraction = if (dark) 0.24f else 0.18f + for (token in ColorToken.entries) { + val mixed = k.category(token).copy(alpha = fraction).compositeOver(s.surface) + val stored = k.categoryContainer(token) + val delta = maxOf(kotlin.math.abs(mixed.red - stored.red), kotlin.math.abs(mixed.green - stored.green), kotlin.math.abs(mixed.blue - stored.blue)) + if (delta > 3f / 255f) failures += "$mode $token container differs from the 18/24 % mix by ${(delta * 255).toInt()}" + } + } + assertTrue(failures.joinToString("\n"), failures.isEmpty()) + } +}