From b24c8b5ba378d27b7082fe43f1a5194723ff43b5 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 18 Oct 2026 11:35:06 +0200 Subject: [PATCH] M6b: core:design fixes after the M7 wave MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs: viewerPercentOf reads basis points (Samples in bp); FullSplitPicker and the split labels use editor_split_full_on_you ("Allt på dig"); AmountInput.of restores money with both decimals (AmountInput.percent for ratio fields), replacing M7c's moneyInput; quickRowCategories does the D-060 fill itself; KvittScaffold resolves resource-backed snackbars (showKvittSnackbar) while shown. Gaps: ConfirmDialog optional and styled body; KvittButton loading form (M7a uses it, ProgressBottomButton removed); InviteCodeField wraps after a group, placeholder, imeAction; EmptyState icon tile and actionEnabled; DateFormatter.monthName and dayRangeLong; AnalyticsFilterBar periodOwnRow; segmentedItem builder; screen tokens in KvittDimens (OnboardingDimens removed); common_ok, common_undo; module-proposed keys recorded in strings.csv; quick-row labels end in an ellipsis on line two. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- .../kvitt/design/catalogue/Catalogue.kt | 40 ++++++- .../kvitt/design/catalogue/Samples.kt | 9 +- .../se/hagstedt/kvitt/design/chart/Charts.kt | 10 ++ .../hagstedt/kvitt/design/component/Common.kt | 87 +++++++++++---- .../hagstedt/kvitt/design/component/Editor.kt | 67 ++++++++---- .../kvitt/design/component/Environment.kt | 34 ++++++ .../hagstedt/kvitt/design/component/Fields.kt | 3 + .../kvitt/design/component/Scaffold.kt | 102 +++++++++++++++++- .../hagstedt/kvitt/design/component/Split.kt | 25 ++++- .../kvitt/design/format/AmountInput.kt | 23 ++-- .../kvitt/design/format/DateFormatter.kt | 20 +++- .../kvitt/design/theme/KvittDimens.kt | 46 ++++++++ .../design/src/main/res/values-sv/strings.xml | 3 + .../design/src/main/res/values/strings.xml | 3 + .../design/component/ComponentLogicTest.kt | 24 ++++- .../design/component/ScaffoldSnackbarTest.kt | 80 ++++++++++++++ .../kvitt/design/format/AmountInputTest.kt | 15 ++- .../kvitt/design/format/DateFormatterTest.kt | 11 ++ .../feature/account/AccountEditorScreen.kt | 2 +- .../feature/analytics/AnalyticsScreen.kt | 7 +- .../kvitt/feature/analytics/AnalyticsWords.kt | 16 +-- .../src/main/res/values-sv/strings.xml | 4 - .../analytics/src/main/res/values/strings.xml | 4 - .../feature/expense/ExpenseDetailScreen.kt | 6 +- .../feature/expense/ExpenseEditorScreen.kt | 15 +-- .../feature/expense/ExpenseEditorViewModel.kt | 26 ++--- .../src/main/res/values-sv/strings.xml | 2 - .../expense/src/main/res/values/strings.xml | 2 - .../expense/ExpenseEditorViewModelTest.kt | 6 +- .../hagstedt/kvitt/feature/home/HomeScreen.kt | 14 +-- .../home/src/main/res/values-sv/strings.xml | 2 - .../home/src/main/res/values/strings.xml | 2 - .../feature/onboarding/OnboardingDimens.kt | 41 ------- .../feature/onboarding/OnboardingParts.kt | 51 +-------- .../kvitt/feature/onboarding/PairingScreen.kt | 14 +-- .../feature/onboarding/ProfileStepScreen.kt | 20 ++-- .../kvitt/feature/onboarding/RemovedScreen.kt | 9 +- .../feature/settings/about/AboutScreen.kt | 4 +- .../recurring/RecurringEditorScreen.kt | 6 +- .../recurring/RecurringEditorViewModel.kt | 3 +- .../settings/recurring/RecurringForm.kt | 2 +- .../src/main/res/values-sv/strings.xml | 10 +- .../settings/src/main/res/values/strings.xml | 10 +- .../screenshot/RecurringScreenshotTest.kt | 2 +- .../kvitt/feature/settle/SettleScreen.kt | 4 +- .../feature/settle/SettlementDetailScreen.kt | 5 +- .../settle/src/main/res/values-sv/strings.xml | 3 +- .../settle/src/main/res/values/strings.xml | 3 +- docs/ux/strings.csv | 19 ++++ 49 files changed, 655 insertions(+), 261 deletions(-) create mode 100644 android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ScaffoldSnackbarTest.kt delete mode 100644 android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingDimens.kt 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 index c340b8a..97ead2f 100644 --- 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 @@ -127,6 +127,10 @@ import se.hagstedt.kvitt.model.Share import se.hagstedt.kvitt.model.SplitMode import se.hagstedt.kvitt.model.UpdateState import kotlinx.datetime.LocalDate +import androidx.compose.ui.res.stringResource +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.component.styledStringResource +import se.hagstedt.kvitt.design.component.viewerPercentOf // 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. @@ -319,10 +323,14 @@ fun TextFieldSheet() { @Composable fun QuickRowSheet() { + // No usage yet (D-060): the first four in sort_order and the selected one fifth. 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, {}, {}) + // A long custom name: two lines, ellipsis on the second, no hyphenation (D-081). + val custom = Samples.defaults + Samples.renovation + CategoryQuickRow(quickRowCategories(custom, listOf(Samples.renovation.id, Samples.groceries.id), Samples.groceries.id), Samples.groceries.id, {}, {}) } @Composable @@ -336,7 +344,7 @@ fun ChipsSheet() { AccountChip("Vardag", 0, {}) DateChip("2026-10-17", {}) PayerChip(Samples.jacob, Samples.anna, {}) - SplitChip(defaultModeLabel(50), open = false, onClick = {}) + SplitChip(defaultModeLabel(viewerPercentOf(Samples.vardag.defaultShares, Samples.jacob.id)), open = false, onClick = {}) } EditorChipRow { DateChip("2026-10-16", {}) @@ -522,6 +530,14 @@ fun FilterSheet() { 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")) + // A custom range: the period chip on its own row, so the label is not cut (design frame S-11-analytics-week). + AnalyticsFilterBar( + w("Anpassad · 24 aug.–17 okt.", "Custom · 24 Aug–17 Oct"), w("Period: anpassad, 24 augusti–17 oktober", "Period: custom, 24 August–17 October"), {}, + FilterToggle(w("Indelning", "Interval"), listOf(w("Månad", "Month"), w("Vecka", "Week")), 1, {}), + FilterToggle(w("Mått", "Measure"), listOf(w("Hushållet", "Household"), w("Min del", "My share")), 1, {}), + FilterToggle(w("Gruppera efter", "Group by"), listOf(w("Kategori", "Category"), w("Konto", "Account")), 1, {}), + periodOwnRow = true, + ) } @Composable @@ -568,6 +584,11 @@ fun ButtonSheet() { } 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) + KvittButton(w("Parar ihop…", "Pairing…"), {}, Modifier.fillMaxWidth(), size = ButtonSize.Medium, icon = KvittIcon.Link, loading = true) + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + KvittButton(stringResource(R.string.common_save), {}, loading = true) + KvittButton(w("Synka nu", "Sync now"), {}, kind = ButtonKind.Tonal, icon = KvittIcon.Sync, loading = true) + } } @Composable @@ -623,12 +644,24 @@ fun EmptyStateSheet() { illustration = listOf(Samples.other, Samples.other, Samples.other), illustrationContainer = true, action = w("Byt period", "Change period"), actionIcon = KvittIcon.DateRange, actionKind = ButtonKind.Tonal, ) + // S-8 / S-10 missing, offline: the icon tile and the online-only action drawn disabled with the hint. + EmptyState( + w("Utgiften finns inte på telefonen än", "This expense is not on the phone yet"), + w("Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.", "It may have just been added on another phone. Sync to fetch it."), + icon = KvittIcon.SyncProblem, + action = w("Synka nu", "Sync now"), actionIcon = KvittIcon.Sync, actionKind = ButtonKind.Tonal, actionEnabled = false, + ) } @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) + ConfirmDialogContent( + KvittIcon.InstallMobile, stringResource(R.string.update_permission_title), null, stringResource(R.string.common_continue), stringResource(R.string.update_later), {}, {}, + tertiaryTile = true, confirmIcon = KvittIcon.ArrowForward, styledBody = styledStringResource(R.string.update_permission_body), + ) + // No body: a discard question (S-6, S-7, S-9, S-19). + ConfirmDialogContent(KvittIcon.Close, stringResource(R.string.common_discard_title), null, stringResource(R.string.common_discard), stringResource(R.string.common_keep_editing), {}, {}) } @Composable @@ -647,6 +680,7 @@ fun MenuSheet() { @Composable fun InviteSheet() { InviteCodeDisplay("7K3MQX9D2HPA") + InviteCodeField("", {}, w("Klistra in", "Paste"), {}, placeholder = "XXXX-XXXX-XXXX") 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.")) } @@ -721,7 +755,7 @@ val CatalogueSheets: List Unit>> = li 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", + "navigation_bar", "add_expense_fab", "sync_status_pill", "invite_code", ) @Composable 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 index ba6bb40..5074c23 100644 --- 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 @@ -58,15 +58,18 @@ object Samples { 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") + /** A long custom name: two lines with an ellipsis in the quick row (D-081). */ + val renovation = Category(CategoryId("01920000-0000-7000-8000-0000000000c2"), null, "Renovering av sommarstugan", "build", ColorToken.BROWN, 120, archived = false) 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) + // Default shares are basis points (share_bp, SPEC §14.2.4, AC-6.1). + private val shares5050 = mapOf(jacob.id to 5_000, anna.id to 5_000) 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 huset = Account(AccountId("01920000-0000-7000-8000-0000000000b2"), "Huset", mapOf(jacob.id to 6_000, anna.id to 4_000), "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 sommarstugan = Account(AccountId("01920000-0000-7000-8000-0000000000b5"), "Sommarstugan i Österåker 2027", mapOf(jacob.id to 6_000, anna.id to 4_000), "2026-10-17T09:00:00.000Z", archived = false) val accounts = listOf(vardag, huset, japan, gotland) val vardagSummary = AccountSummary(vardag, 118_450, 11) 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 index 87ec419..9d9efa2 100644 --- 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 @@ -307,6 +307,8 @@ data class FilterToggle(val groupLabel: String, val options: List, val s /** * 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. + * [periodOwnRow] puts the period chip on a row of its own and the granularity toggle on the next, full width, so a long + * custom range label ("Anpassad · 24 aug.–17 okt.") is not truncated (design frame S-11-analytics-week). */ @Composable fun AnalyticsFilterBar( @@ -317,6 +319,7 @@ fun AnalyticsFilterBar( measure: FilterToggle, groupBy: FilterToggle, modifier: Modifier = Modifier, + periodOwnRow: Boolean = false, ) { val large = LocalDensity.current.fontScale >= KvittDimens.LARGE_FONT_SCALE val chip: @Composable (Modifier) -> Unit = { m -> @@ -343,6 +346,13 @@ fun AnalyticsFilterBar( toggle(granularity, Modifier.fillMaxWidth()) toggle(measure, Modifier.fillMaxWidth()) toggle(groupBy, Modifier.fillMaxWidth()) + } else if (periodOwnRow) { + chip(Modifier) + toggle(granularity, Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { + toggle(measure, Modifier.weight(1f)) + toggle(groupBy, Modifier.weight(1f)) + } } else { Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) { chip(Modifier.weight(1f)) 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 index 2e9b4c9..7da1203 100644 --- 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 @@ -19,6 +19,7 @@ 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.CircularProgressIndicator import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -41,10 +42,12 @@ 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.disabled 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.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -85,9 +88,22 @@ fun KvittCard( /** 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. */ +/** One item of a [SegmentedGroup]: its shape and fill. Built by [SegmentedGroup], or by [segmentedItem] row by row. */ class SegmentedItemScope internal constructor(val index: Int, val count: Int, val shape: Shape, val fill: Color) +/** + * The scope of item [index] of [count] of a segmented group, for building the group one row at a time where + * [SegmentedGroup] cannot hold the rows, such as one `LazyColumn` item per row (S-12): the item gets the §3.5 group + * shape (outer corners 24 on the ends, inner 8) and the [fill]; the caller spaces the items with + * `KvittTheme.spacing.groupGap`. Unlike [SegmentedGroup] it casts no shared group shadow (one per item would show + * between the rows), so lazily built groups are flat in light theme too. + */ +@Composable +fun segmentedItem(index: Int, count: Int, fill: GroupFill = GroupFill.Card): SegmentedItemScope { + val color = if (fill == GroupFill.Card) KvittTheme.colors.card else MaterialTheme.colorScheme.surfaceContainerLow + return SegmentedItemScope(index, count, KvittTheme.shapes.groupItem(index, count), 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). @@ -110,7 +126,10 @@ enum class ButtonKind { Filled, Tonal, Tertiary, Outlined, Text, DangerText, Err /** 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). */ +/** + * A Kvitt button: radius full, label-large weight 600, optional leading icon (design-spec §3.11). [loading] shows the + * in-button progress form (a 20 dp indicator in the content colour instead of the icon, the fill kept, not clickable). + */ @Composable fun KvittButton( text: String, @@ -122,6 +141,7 @@ fun KvittButton( iconFilled: Boolean = false, enabled: Boolean = true, contentColor: Color? = null, + loading: Boolean = false, ) { val scheme = MaterialTheme.colorScheme val (container, content) = when (kind) { @@ -145,22 +165,28 @@ fun KvittButton( ) val padding = PaddingValues(horizontal = if (kind == ButtonKind.Text || kind == ButtonKind.DangerText) KvittTheme.spacing.s3 else horizontal) val label: @Composable RowScope.() -> Unit = { - if (icon != null) { + if (loading) { + CircularProgressIndicator(Modifier.size(KvittDimens.buttonProgress), color = content, strokeWidth = KvittDimens.buttonProgressStroke) + Spacer(Modifier.width(KvittTheme.spacing.s2)) + } else 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) + // Loading (design-spec §3.11, S-1 "Parar ihop…"): the enabled look with a 20 dp progress indicator in the content + // colour in place of the icon; taps do nothing and TalkBack reads the button as disabled. + val sized = modifier.heightIn(min = height).then(if (loading) Modifier.semantics { disabled() } else Modifier) + val click = if (loading) ({}) else onClick if (kind == ButtonKind.Outlined) { OutlinedButton( - onClick, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, + click, 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) + TextButton(click, 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) + Button(click, sized, enabled = enabled, shape = CircleShape, colors = colors, contentPadding = padding, content = label) } } @@ -311,8 +337,11 @@ fun SettingsRow( } /** - * Empty state: flat section, centred, decorative illustration (three rotated category tiles), title, body and an - * optional medium button (design-spec §3.11). + * Empty state: flat section, centred, decorative illustration, title, body and an optional medium button + * (design-spec §3.11). The illustration is either the pile of three rotated category tiles ([illustration]) or, with + * [icon], one illustration tile (Cookie9, fallback circle; `primary-container` with the icon in + * `on-primary-container`), as in the S-8 / S-10 missing states (`sync_problem`). An online-only action is drawn + * disabled with [actionEnabled] false and the "Kräver anslutning" hint ([actionDisabledHint]) under it (SPEC §9.9). */ @Composable fun EmptyState( @@ -321,23 +350,38 @@ fun EmptyState( modifier: Modifier = Modifier, illustration: List = emptyList(), illustrationContainer: Boolean = false, + icon: KvittIcon? = null, action: String? = null, actionIcon: KvittIcon? = null, actionKind: ButtonKind = ButtonKind.Filled, + actionEnabled: Boolean = true, + actionDisabledHint: String? = stringResource(R.string.common_needs_connection), onAction: () -> Unit = {}, ) { + val scheme = MaterialTheme.colorScheme Column( modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card) + .background(scheme.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) + if (icon != null) { + Box(Modifier.size(KvittDimens.emptyIconTile).background(scheme.primaryContainer, KvittTheme.shapes.illustration).clearAndSetSemantics { }, contentAlignment = Alignment.Center) { + KvittIconImage(icon, null, filled = true, size = KvittDimens.emptyIconTile * KvittDimens.TILE_ICON_RATIO, tint = scheme.onPrimaryContainer) + } + } else 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) + Text(body, style = MaterialTheme.typography.bodyLarge, color = scheme.onSurfaceVariant, textAlign = TextAlign.Center) + if (action != null) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + KvittButton(action, onAction, kind = actionKind, size = ButtonSize.Medium, icon = actionIcon, enabled = actionEnabled) + if (!actionEnabled && actionDisabledHint != null) NeedsConnectionHint(text = actionDisabledHint) + } + } } } @@ -363,13 +407,15 @@ private fun TilePile(categories: List, container: Boolean) { /** * 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). + * container), centred title, muted body, right-aligned dismiss and confirm (design-spec §3.11). [body] null hides the + * body line (a discard question needs none). [styledBody], when given, replaces [body] with styled text, for a body + * with bold parts from the resources (`update_permission_body`, S-17; see [styledStringResource]). */ @Composable fun ConfirmDialog( icon: KvittIcon, title: String, - body: String, + body: String?, confirm: String, dismiss: String, onConfirm: () -> Unit, @@ -377,9 +423,12 @@ fun ConfirmDialog( destructive: Boolean = false, tertiaryTile: Boolean = false, confirmIcon: KvittIcon? = null, + styledBody: AnnotatedString? = null, extra: (@Composable ColumnScope.() -> Unit)? = null, ) { - Dialog(onDismissRequest = onDismiss) { ConfirmDialogContent(icon, title, body, confirm, dismiss, onConfirm, onDismiss, destructive, tertiaryTile, confirmIcon, extra) } + Dialog(onDismissRequest = onDismiss) { + ConfirmDialogContent(icon, title, body, confirm, dismiss, onConfirm, onDismiss, destructive, tertiaryTile, confirmIcon, styledBody, extra) + } } /** The content of [ConfirmDialog], also used to screenshot the dialog without a window. */ @@ -387,7 +436,7 @@ fun ConfirmDialog( fun ConfirmDialogContent( icon: KvittIcon, title: String, - body: String, + body: String?, confirm: String, dismiss: String, onConfirm: () -> Unit, @@ -395,6 +444,7 @@ fun ConfirmDialogContent( destructive: Boolean = false, tertiaryTile: Boolean = false, confirmIcon: KvittIcon? = null, + styledBody: AnnotatedString? = null, extra: (@Composable ColumnScope.() -> Unit)? = null, ) { val scheme = MaterialTheme.colorScheme @@ -403,13 +453,14 @@ fun ConfirmDialogContent( tertiaryTile -> scheme.tertiaryContainer to scheme.onTertiaryContainer else -> scheme.secondaryContainer to scheme.onSecondaryContainer } + val text = styledBody ?: body?.let(::AnnotatedString) 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) + if (text != null) Text(text, 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) 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 index 7dbcc4c..fdd7224 100644 --- 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 @@ -25,6 +25,10 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -201,20 +205,19 @@ fun DescriptionField(value: String, onValueChange: (String) -> Unit, error: Stri } /** - * 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). + * The five tiles of the quick row (design-spec §3.7, UX-3, D-060): [frequent] is the raw + * `ExpenseEditorOptions.frequentCategoryIds[accountId]` (the account's used active categories by use count, D-059; + * possibly empty), filled up to five from the active categories of [categories] in `sort_order`. When [selected] is + * not among the five it takes the fifth place. Archived categories are never filled in; an archived [selected] (an + * old expense being edited) still takes the fifth place. Callers pass the list as the repository gives it. */ -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 +fun quickRowCategories(categories: List, frequent: List, selected: CategoryId?): List { + val active = categories.filter { !it.archived } + val activeById = active.associateBy { it.id } + val chosen = (frequent.mapNotNull { activeById[it] } + active.sortedBy { it.sortOrder }) + .distinctBy { it.id } + .take(KvittDimens.QUICK_ROW_TILES) + val pick = selected?.let { id -> categories.firstOrNull { it.id == id } } ?: return chosen return if (chosen.any { it.id == pick.id }) chosen else chosen.take(KvittDimens.QUICK_ROW_TILES - 1) + pick } @@ -281,16 +284,40 @@ fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, mod } } } - 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, - ) + TileLabel(name, selected) } } +/** + * The label of a quick-row or grid item: up to two centred lines (D-081). The default names fit (a long single word such + * as "Entertainment" still hyphenates); a longer custom name ends its second line in "…" instead of a cut "som-". The + * ellipsis is made here, not by the platform: Android shifts a centred ellipsized line by its unellipsized width, which + * cut its start. + */ +@Composable +private fun TileLabel(name: String, selected: Boolean) { + val scheme = MaterialTheme.colorScheme + var keep by remember(name) { mutableIntStateOf(name.length) } + val shown = if (keep >= name.length) name else name.take(keep).trimEnd() + ELLIPSIS + Text( + shown, + // TalkBack reads the full name when the label is cut. + Modifier.fillMaxWidth().then(if (shown != name) Modifier.clearAndSetSemantics { contentDescription = name } else Modifier), + 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 = KvittDimens.TILE_LABEL_LINES, + onTextLayout = { layout -> + if (layout.hasVisualOverflow && keep > 1) { + // First overflow: cut at the end of the last visible line; then one character at a time until "…" fits. + keep = if (keep >= name.length) (layout.getLineEnd(KvittDimens.TILE_LABEL_LINES - 1) - 1).coerceIn(1, name.length - 1) else keep - 1 + } + }, + ) +} + +private const val ELLIPSIS = "\u2026" + @Composable private fun AllItem(onAll: () -> Unit, modifier: Modifier) { val scheme = MaterialTheme.colorScheme 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 index f004afd..63dbe57 100644 --- 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 @@ -1,5 +1,8 @@ package se.hagstedt.kvitt.design.component +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.StyleSpan import androidx.annotation.StringRes import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon @@ -12,8 +15,14 @@ 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.platform.LocalResources import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone @@ -105,6 +114,31 @@ fun listDate(isoDate: String): String = DateFormatter.listDate(DateFormatter.par @ReadOnlyComposable fun detailDate(isoDate: String): String = DateFormatter.detailDate(isoDate, kvittLanguage()) +/** + * A string resource with its `` and `` parts kept as styles (stringResource drops them), for text that bolds a + * few words, such as the two Android setting names in `update_permission_body` (S-17). + */ +@Composable +@ReadOnlyComposable +fun styledStringResource(@StringRes id: Int): AnnotatedString = LocalResources.current.getText(id).toAnnotatedString() + +private fun CharSequence.toAnnotatedString(): AnnotatedString { + if (this !is Spanned) return AnnotatedString(toString()) + val spanned = this + return buildAnnotatedString { + append(spanned.toString()) + spanned.getSpans(0, spanned.length, StyleSpan::class.java).forEach { span -> + val style = when (span.style) { + Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold) + Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + Typeface.BOLD_ITALIC -> SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic) + else -> null + } + if (style != null) addStyle(style, spanned.getSpanStart(span), spanned.getSpanEnd(span)) + } + } +} + /** The display name of a person as the viewer reads it: "Du" for the viewer, else the display name. */ @Composable @ReadOnlyComposable 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 index e818e63..0c8e529 100644 --- 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 @@ -32,6 +32,7 @@ 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.text.input.VisualTransformation import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.foundation.shape.CornerSize @@ -67,6 +68,7 @@ fun KvittTextField( textStyle: TextStyle = MaterialTheme.typography.bodyLarge, suffix: String? = null, trailing: (@Composable () -> Unit)? = null, + visualTransformation: VisualTransformation = VisualTransformation.None, ) { val interaction = remember { MutableInteractionSource() } val focused by interaction.collectIsFocusedAsState() @@ -97,6 +99,7 @@ fun KvittTextField( maxLines = maxLines, keyboardOptions = KeyboardOptions(capitalization = capitalization, keyboardType = keyboardType, imeAction = imeAction), keyboardActions = KeyboardActions(onAny = { onIme() }), + visualTransformation = visualTransformation, interactionSource = interaction, shape = shape, colors = TextFieldDefaults.colors( 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 index 6179ef0..ceb8e4c 100644 --- 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 @@ -1,5 +1,7 @@ package se.hagstedt.kvitt.design.component +import android.content.res.Resources +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,19 +27,29 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.ShortNavigationBar import androidx.compose.material3.ShortNavigationBarItem import androidx.compose.material3.ShortNavigationBarItemDefaults +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.SnackbarVisuals import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager 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.AnnotatedString +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow @@ -182,9 +194,42 @@ fun AddExpenseFab(onClick: () -> Unit, modifier: Modifier = Modifier, expanded: } } +/** + * A snackbar whose words are string resources: [text] with [args], and an optional [action] label. [KvittScaffold] + * resolves them while the snackbar is on screen, so its text follows the current configuration (an app-language change + * while it shows) instead of keeping the language it was shown in. [message] and [actionLabel] hold the words as + * resolved when shown, for any other host. Show it with [showKvittSnackbar]. Durations per design-spec §3.11: 4 s + * (`Short`), 10 s with an action (`Long`). + */ +class KvittSnackbarVisuals( + @param:StringRes val text: Int, + val args: List, + @param:StringRes val action: Int?, + override val message: String, + override val actionLabel: String?, +) : SnackbarVisuals { + override val withDismissAction: Boolean get() = false + override val duration: SnackbarDuration get() = if (action != null) SnackbarDuration.Long else SnackbarDuration.Short +} + +/** + * Shows a resource-backed snackbar ([KvittSnackbarVisuals]) in a [KvittScaffold]: [text] formatted with [args], an + * optional [action] label ("Försök igen", "Ångra"). [resources] (for example `LocalResources.current`) resolves the + * fallback words; the scaffold re-resolves them on every composition. + */ +suspend fun SnackbarHostState.showKvittSnackbar( + resources: Resources, + @StringRes text: Int, + args: List = emptyList(), + @StringRes action: Int? = null, +): SnackbarResult = showSnackbar( + KvittSnackbarVisuals(text, args, action, resources.getString(text, *args.toTypedArray()), action?.let { resources.getString(it) }), +) + /** * 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. + * navigation; the status-bar inset is handled by the app bar. A snackbar shown with [showKvittSnackbar] is resolved + * from its resources while it shows, so it follows the current language; one shown with a plain `String` keeps it. */ @Composable fun KvittScaffold( @@ -201,7 +246,14 @@ fun KvittScaffold( 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() }) } + if (snackbarHostState != null) { + SnackbarHost(snackbarHostState) { data -> + val visuals = data.visuals + val message = if (visuals is KvittSnackbarVisuals) stringResource(visuals.text, *visuals.args.toTypedArray()) else visuals.message + val action = if (visuals is KvittSnackbarVisuals) visuals.action?.let { stringResource(it) } else visuals.actionLabel + KvittSnackbar(message, Modifier.padding(KvittTheme.spacing.s4), action, { data.performAction() }) + } + } }, containerColor = MaterialTheme.colorScheme.surface, content = content, @@ -248,20 +300,41 @@ fun InviteCodeDisplay(code: String, modifier: Modifier = Modifier) { /** * 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). + * The value wraps after a group when it does not fit on one line (200 % font scale: "7K3M-QX9D-" over "2HPA"), never + * cut. [placeholder] is shown while empty ("XXXX-XXXX-XXXX"). [imeAction] `Next` moves the focus to the next field + * unless [onIme] is given; `Done` runs [onIme]. */ @Composable -fun InviteCodeField(value: String, onValueChange: (String) -> Unit, pasteLabel: String, onPaste: () -> Unit, modifier: Modifier = Modifier, error: String? = null, enabled: Boolean = true) { +fun InviteCodeField( + value: String, + onValueChange: (String) -> Unit, + pasteLabel: String, + onPaste: () -> Unit, + modifier: Modifier = Modifier, + error: String? = null, + enabled: Boolean = true, + placeholder: String? = null, + imeAction: ImeAction = ImeAction.Done, + onIme: (() -> Unit)? = null, +) { + val focus = LocalFocusManager.current KvittTextField( value = groupInviteCode(normalizeInviteCode(value)), onValueChange = { onValueChange(normalizeInviteCode(it)) }, label = stringResource(R.string.common_invite_code), modifier = modifier, error = error, + placeholder = placeholder, + singleLine = false, + maxLines = INVITE_MAX_LINES, capitalization = KeyboardCapitalization.Characters, keyboardType = KeyboardType.Ascii, + imeAction = imeAction, + onIme = onIme ?: { if (imeAction == ImeAction.Next) focus.moveFocus(FocusDirection.Next) }, textStyle = MaterialTheme.typography.titleLarge, enabled = enabled, readOnly = !enabled, + visualTransformation = GroupBreaks, trailing = if (enabled) { { KvittButton(pasteLabel, onPaste, kind = ButtonKind.Text, icon = KvittIcon.ContentPaste) } } else { @@ -269,3 +342,26 @@ fun InviteCodeField(value: String, onValueChange: (String) -> Unit, pasteLabel: }, ) } + +/** Three groups of four wrap onto at most three lines, one group per line at the largest font scale. */ +private const val INVITE_MAX_LINES = 3 + +/** + * Shows a zero-width space after every hyphen of the grouped code, so a line may break after any group: the line + * breaker does not break between a hyphen and a digit ("QX9D-2HPA"), which cut the value at 200 % font scale. The value + * itself is unchanged. + */ +private object GroupBreaks : VisualTransformation { + private const val BREAK = '\u200B' + + override fun filter(text: AnnotatedString): TransformedText { + val original = text.text + val shown = buildString { original.forEach { c -> append(c); if (c == '-') append(BREAK) } } + val mapping = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = offset + original.take(offset).count { it == '-' } + + override fun transformedToOriginal(offset: Int): Int = offset - shown.take(offset).count { it == BREAK } + } + return TransformedText(AnnotatedString(shown), mapping) + } +} 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 index 267247d..d22cdb7 100644 --- 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 @@ -445,7 +445,7 @@ fun FullSplitPicker(me: User, partner: User, bearer: UserId, onBearer: (UserId) 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)) }, + spoken = people.map { fullOnLabel(it) }, leading = { PersonAvatar(people[it], KvittDimens.avatarFullPicker) }, ) SplitBar(if (bearer == me.id) 100f else 0f) @@ -453,6 +453,25 @@ fun FullSplitPicker(me: User, partner: User, bearer: UserId, onBearer: (UserId) } } -/** 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 +/** + * "Allt på dig" for the viewer (`editor_split_full_on_you`, the object form Swedish needs), else "Allt på Anna" + * (`editor_split_full_on`). Used by the full-split picker, the split chip (S-7, S-19) and the split fact (S-8). + */ +@Composable +fun fullOnLabel(bearer: User): String = + if (LocalKvittHousehold.current.isViewer(bearer.id)) { + stringResource(R.string.editor_split_full_on_you) + } else { + stringResource(R.string.editor_split_full_on, bearer.displayName) + } + +/** + * The viewer's whole percent of an account's default split, for the default label ("Kontots standard 60/40"): + * [defaultShares] holds basis points (`share_bp`, SPEC §14.2.4, AC-6.1), so 6000 gives 60; a missing entry gives 50. + * Fractions of a percent are cut (6050 gives 60), as the label shows whole percents. + */ +fun viewerPercentOf(defaultShares: Map, viewer: UserId): Int = (defaultShares[viewer] ?: HALF_BP) / BP_PER_PERCENT + +private const val HALF_BP = 5_000 +private const val BP_PER_PERCENT = 100 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 index b701e19..9462f47 100644 --- 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 @@ -62,14 +62,23 @@ data class AmountInput(val raw: String = "") { /** 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 + /** + * Restores the text of a stored amount for editing, always with both decimals, as money is shown (SPEC §9.8): + * 124 760 -> "1247.60" (shown "1 247,60"), 1 240 000 -> "12400.00", 41 200 -> "412.00", 5 -> "0.05". + */ + fun of(ore: Long): AmountInput = AmountInput("${ore / 100}.${(ore % 100).toString().padStart(2, '0')}") + + /** + * Restores a percent field from basis points without trailing zeros, as the ratio fields are typed: + * 6000 -> "60", 6050 -> "60.5", 3333 -> "33.33". + */ + fun percent(bp: Long): AmountInput { + val whole = bp / 100 + val hundredths = bp % 100 val raw = when { - cents == 0L -> whole.toString() - cents % 10 == 0L -> "$whole.${cents / 10}" - else -> "$whole.${cents.toString().padStart(2, '0')}" + hundredths == 0L -> whole.toString() + hundredths % 10 == 0L -> "$whole.${hundredths / 10}" + else -> "$whole.${hundredths.toString().padStart(2, '0')}" } return AmountInput(raw) } 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 index 6ab684e..9c023d8 100644 --- 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 @@ -138,6 +138,20 @@ object DateFormatter { "${listDate(start, today, language)}$EN_DASH${listDate(end, today, language)}" } + /** + * A day range written out, always with the year, for captions (the S-11 week caption): `24 augusti–17 oktober 2026`, + * `1–30 november 2026`, across years `28 december 2025–3 januari 2026` / `24 August–17 October 2026`. Full month + * names, as in running text (D-065 keeps the short forms for lists and axes). + */ + fun dayRangeLong(start: LocalDate, end: LocalDate, language: KvittLanguage): String { + val endText = "${end.day} ${monthName(end.month.index + 1, language)} ${end.year}" + return when { + start.year == end.year && start.month == end.month -> "${start.day}$EN_DASH$endText" + start.year == end.year -> "${start.day} ${monthName(start.month.index + 1, language)}$EN_DASH$endText" + else -> "${start.day} ${monthName(start.month.index + 1, language)} ${start.year}$EN_DASH$endText" + } + } + /** * 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`). @@ -164,9 +178,13 @@ object DateFormatter { 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 = + /** The month (1–12) in running text: `oktober` / `October` ("11 utgifter i oktober", S-4). */ + fun monthName(month: Int, language: KvittLanguage): String = if (language == KvittLanguage.SWEDISH) SV_MONTHS[month - 1] else EN_MONTHS[month - 1] + /** The month of a `YYYY-MM` period in running text: `oktober` / `October`. */ + fun monthName(period: String, language: KvittLanguage): String = monthName(parsePeriod(period).second, language) + private fun parsePeriod(period: String): Pair { val year = period.substringBefore('-').toInt() val month = period.substringAfter('-').take(2).toInt() 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 index a704bcc..5332199 100644 --- 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 @@ -115,6 +115,8 @@ object KvittDimens { const val QUICK_ROW_COLUMNS = 6 const val QUICK_ROW_COLUMNS_LARGE = 3 const val QUICK_ROW_TILES = 5 + /** Category item labels: at most two lines, the second ends in "…" when a custom name is longer (D-081). */ + const val TILE_LABEL_LINES = 2 const val GRID_SHEET_COLUMNS = 4 const val GRID_SHEET_COLUMNS_LARGE = 3 const val LARGE_FONT_SCALE = 1.5f @@ -198,6 +200,9 @@ object KvittDimens { val buttonSmallHorizontal = 20.dp val buttonMediumHorizontal = 26.dp val buttonMediumIcon = 24.dp + /** In-button progress of a loading button (S-1 "Parar ihop…"): 20 dp, stroke 2.5. */ + val buttonProgress = 20.dp + val buttonProgressStroke = 2.5.dp val badgeVertical = 2.dp val badgeHorizontal = 10.dp val sectionHeaderStart = 4.dp @@ -207,6 +212,8 @@ object KvittDimens { val emptyGap = 10.dp val illustrationWidth = 170.dp val illustrationHeight = 110.dp + /** The single illustration tile of an empty state with an icon (S-8 / S-10 missing): Cookie9 72, icon at 52 %. */ + val emptyIconTile = 72.dp val dialogIconTile = 56.dp val dialogPadding = 24.dp val dialogGap = 14.dp @@ -222,4 +229,43 @@ object KvittDimens { const val DISABLED_CONTENT_ALPHA = 0.38f const val DISABLED_CONTAINER_ALPHA = 0.12f const val DELETED_ALPHA = 0.62f + + // Screen measures of design-spec §4 that the feature modules use (moved here from the features, M6b). + /** App mark (Sunny, `primary`, `receipt_long`): S-1 brand block 88 with icon 44; S-22 About 96 with icon 48. */ + val appMark = 88.dp + val appMarkIcon = 44.dp + val aboutAppMark = 96.dp + val aboutAppMarkIcon = 48.dp + /** S-1 brand block padding 28 top / 12 bottom; onboarding column gap 14 (S-1, S-2, S-18). */ + val brandTop = 28.dp + val brandBottom = 12.dp + val onboardingGap = 14.dp + /** Pinned bottom action bar of S-1, S-2 and S-18: padding 12 vertical / 16 horizontal. */ + val bottomBarVertical = 12.dp + val bottomBarHorizontal = 16.dp + /** S-1 help line icon 20. */ + val helpIcon = 20.dp + /** S-2 intro padding 24 top / 4 sides / 4 bottom; profile card padding 22 vertical / 20 horizontal. */ + val profileIntroTop = 24.dp + val profileIntroSide = 4.dp + val profileIntroBottom = 4.dp + val profileCardVertical = 22.dp + val profileCardHorizontal = 20.dp + /** S-2 avatar badge: a 40 dp `primary` circle with `add_a_photo` 20 and a 3 dp `card` ring. */ + val avatarBadge = 40.dp + val avatarBadgeIcon = 20.dp + val avatarBadgeRing = 3.dp + /** S-18 device removed: padding top 80, Cookie9 tile 112 with `phonelink_erase` 52. */ + val removedTop = 80.dp + val removedTile = 112.dp + val removedIcon = 52.dp + /** S-4 empty: the welcome card's two avatars, 44 dp, overlapping by 10. */ + val welcomeAvatar = 44.dp + val welcomeAvatarOverlap = 10.dp + /** S-4 loading: the sync pill skeleton is 200 dp wide (40 high, [pillMinHeight]). */ + val skeletonPillWidth = 200.dp + /** S-10 hero: padding 22 vertical / 20 horizontal, `arrow_forward` 28 between the avatars. */ + val settlementHeroVertical = 22.dp + val settlementHeroHorizontal = 20.dp + val settlementArrow = 28.dp } diff --git a/android/core/design/src/main/res/values-sv/strings.xml b/android/core/design/src/main/res/values-sv/strings.xml index 0de8093..02e8c65 100644 --- a/android/core/design/src/main/res/values-sv/strings.xml +++ b/android/core/design/src/main/res/values-sv/strings.xml @@ -16,6 +16,8 @@ Skriv ett namn Försök igen + OK + Ångra Fler alternativ @@ -160,6 +162,7 @@ Exakta belopp Allt på en person Allt på %1$s + Allt på dig Vem står för hela beloppet? diff --git a/android/core/design/src/main/res/values/strings.xml b/android/core/design/src/main/res/values/strings.xml index ebc2901..3d1b6e3 100644 --- a/android/core/design/src/main/res/values/strings.xml +++ b/android/core/design/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Enter a name Try again + OK + Undo More options @@ -162,6 +164,7 @@ Exact amounts All on one person All on %1$s + All on you Who pays the whole amount? 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 index d615f6b..b4ab5f8 100644 --- 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 @@ -45,12 +45,21 @@ class ComponentLogicTest { Category(CategoryId("c$n"), DefaultCategoryKey.entries[n], null, "category", ColorToken.GREY, sort, archived) @Test - fun quickRowFallsBackToSelectedThenSortOrder() { + fun quickRowWithoutUsageFillsFromSortOrderAndPutsTheSelectedFifth() { + // D-060: an empty frequent list is filled from sort_order like any short one; the selected one takes place five. 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", "c5"), quickRowCategories(active, emptyList(), CategoryId("c5")).map { it.id.value }) + assertEquals(listOf("c0", "c1", "c2", "c3", "c4"), quickRowCategories(active, emptyList(), CategoryId("c2")).map { it.id.value }) assertEquals(listOf("c0", "c1", "c2", "c3", "c4"), quickRowCategories(active, emptyList(), null).map { it.id.value }) } + @Test + fun quickRowShortUsageIsFilledUpToFive() { + val active = (0 until 8).map { cat(it, it * 10) } + assertEquals(listOf("c7", "c0", "c1", "c2", "c3"), quickRowCategories(active, listOf(CategoryId("c7")), CategoryId("c1")).map { it.id.value }) + assertEquals(listOf("c7", "c0", "c1", "c2", "c6"), quickRowCategories(active, listOf(CategoryId("c7")), CategoryId("c6")).map { it.id.value }) + } + @Test fun quickRowUsesFrequentAndPutsTheSelectedFifth() { val active = (0 until 8).map { cat(it, it * 10) } @@ -63,6 +72,17 @@ class ComponentLogicTest { 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 }) + assertEquals(listOf("c1", "c2", "c3", "c4", "c5"), quickRowCategories(active, listOf(CategoryId("c0")), null).map { it.id.value }) + // An archived selected category (an old expense being edited) still shows, in the fifth place. + assertEquals(listOf("c1", "c2", "c3", "c4", "c0"), quickRowCategories(active, emptyList(), CategoryId("c0")).map { it.id.value }) + } + + @Test + fun viewerPercentReadsBasisPoints() { + assertEquals(60, viewerPercentOf(mapOf(jacob to 6_000, anna to 4_000), jacob)) + assertEquals(40, viewerPercentOf(mapOf(jacob to 6_000, anna to 4_000), anna)) + assertEquals(50, viewerPercentOf(emptyMap(), jacob)) + assertEquals(60, viewerPercentOf(mapOf(jacob to 6_050, anna to 3_950), jacob)) } @Test diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ScaffoldSnackbarTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ScaffoldSnackbarTest.kt new file mode 100644 index 0000000..0fde2b6 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/ScaffoldSnackbarTest.kt @@ -0,0 +1,80 @@ +package se.hagstedt.kvitt.design.component + +import android.content.res.Configuration +import android.os.LocaleList +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.text.font.FontWeight +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import se.hagstedt.kvitt.core.design.R +import se.hagstedt.kvitt.design.KvittTheme +import java.util.Locale + +/** + * A snackbar shown with [showKvittSnackbar] follows a language change while it is on screen (M6b item 5), and + * [styledStringResource] keeps the `` parts of a resource. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class ScaffoldSnackbarTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun snackbarTextFollowsTheLanguageWhileShown() { + var language by mutableStateOf("sv-SE") + val host = SnackbarHostState() + compose.setContent { + val base = LocalContext.current + val baseConfiguration = LocalConfiguration.current + val context = remember(language) { + base.createConfigurationContext(Configuration(baseConfiguration).apply { setLocales(LocaleList(Locale.forLanguageTag(language))) }) + } + CompositionLocalProvider( + LocalContext provides context, + LocalConfiguration provides context.resources.configuration, + LocalResources provides context.resources, + ) { + val resources = LocalResources.current + LaunchedEffect(Unit) { host.showKvittSnackbar(resources, R.string.common_could_not_reach_server, action = R.string.common_retry) } + KvittTheme { KvittScaffold(snackbarHostState = host) { Text("") } } + } + } + compose.waitForIdle() + compose.onNodeWithText("Kunde inte nå servern").assertExists() + compose.onNodeWithText("Försök igen").assertExists() + language = "en-GB" + compose.waitForIdle() + compose.onNodeWithText("Could not reach the server").assertExists() + compose.onNodeWithText("Try again").assertExists() + } + + @Test + fun styledStringsKeepTheirBoldParts() { + var bold = emptyList() + compose.setContent { + val text = styledStringResource(R.string.update_permission_body) + bold = text.spanStyles.filter { it.item.fontWeight == FontWeight.Bold }.map { text.text.substring(it.start, it.end) } + } + compose.waitForIdle() + assertEquals(2, bold.size) + assertTrue(bold.all { it.isNotBlank() }) + } +} 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 index f78ed41..2d7a60a 100644 --- 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 @@ -60,8 +60,19 @@ class AmountInputTest { @Test fun restoresStoredAmounts() { - assertEquals("1247.6", AmountInput.of(124_760).raw) - assertEquals("412", AmountInput.of(41_200).raw) + assertEquals("1247.60", AmountInput.of(124_760).raw) + assertEquals("1\u00A0247,60", AmountInput.of(124_760).display(KvittLanguage.SWEDISH)) + assertEquals("12\u00A0400,00", AmountInput.of(1_240_000).display(KvittLanguage.SWEDISH)) + assertEquals("412.00", AmountInput.of(41_200).raw) assertEquals("0.05", AmountInput.of(5).raw) + assertEquals(124_760L, AmountInput.of(124_760).ore) + } + + @Test + fun restoresPercentFields() { + assertEquals("60", AmountInput.percent(6_000).raw) + assertEquals("60.5", AmountInput.percent(6_050).raw) + assertEquals("33.33", AmountInput.percent(3_333).raw) + assertEquals("0", AmountInput.percent(0).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 index ab3ec05..bc2173a 100644 --- 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 @@ -86,6 +86,17 @@ class DateFormatterTest { assertEquals("24 aug.–17 okt.", DateFormatter.dayRange(LocalDate(2026, 8, 24), LocalDate(2026, 10, 17), today, SWEDISH)) } + @Test + fun monthNamesAndLongDayRanges() { + assertEquals("oktober", DateFormatter.monthName(10, SWEDISH)) + assertEquals("October", DateFormatter.monthName("2026-10", ENGLISH)) + assertEquals("maj", DateFormatter.monthName("2026-05", SWEDISH)) + assertEquals("24 augusti–17 oktober 2026", DateFormatter.dayRangeLong(LocalDate(2026, 8, 24), LocalDate(2026, 10, 17), SWEDISH)) + assertEquals("24 August–17 October 2026", DateFormatter.dayRangeLong(LocalDate(2026, 8, 24), LocalDate(2026, 10, 17), ENGLISH)) + assertEquals("1–30 november 2026", DateFormatter.dayRangeLong(LocalDate(2026, 11, 1), LocalDate(2026, 11, 30), SWEDISH)) + assertEquals("28 december 2025–3 januari 2026", DateFormatter.dayRangeLong(LocalDate(2025, 12, 28), LocalDate(2026, 1, 3), SWEDISH)) + } + @Test fun isoWeeks() { assertEquals(41, DateFormatter.isoWeek(LocalDate(2026, 10, 5))) diff --git a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt index 280592e..f364827 100644 --- a/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt +++ b/android/feature/account/src/main/kotlin/se/hagstedt/kvitt/feature/account/AccountEditorScreen.kt @@ -131,7 +131,7 @@ fun AccountEditorContent(ui: AccountEditorUi, actions: AccountEditorActions, mod ConfirmDialog( icon = KvittIcon.Delete, title = stringResource(DesignR.string.common_discard_title), - body = "", + body = null, confirm = stringResource(DesignR.string.common_discard), dismiss = stringResource(DesignR.string.common_keep_editing), onConfirm = actions.onDiscard, diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt index 96002f8..9714704 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsScreen.kt @@ -58,6 +58,7 @@ import se.hagstedt.kvitt.model.Granularity import se.hagstedt.kvitt.model.GroupBy import se.hagstedt.kvitt.model.GroupKey import se.hagstedt.kvitt.model.Measure +import se.hagstedt.kvitt.model.RangePreset import se.hagstedt.kvitt.core.design.R as DesignR /** @@ -141,13 +142,15 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) { listOf(stringResource(R.string.analytics_group_by_category), stringResource(R.string.analytics_group_by_account)), GroupBy.entries.indexOf(filter.groupBy), ) { events.onGroupBy(GroupBy.entries[it]) }, + // A custom range label ("Anpassad · 24 aug.–17 okt.") gets a row of its own (design frame S-11-analytics-week). + periodOwnRow = filter.preset == RangePreset.CUSTOM, ) } /** The range caption: "maj–okt 2026 · Hushållet", or "24 aug.–17 okt. · 8 veckor" in week granularity. */ @Composable private fun totalCaption(data: AnalyticsState): String { - val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage()) + val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage(), long = true) val second = when { data.filter.granularity == Granularity.WEEK -> pluralStringResource(R.plurals.analytics_weeks, data.buckets.size, data.buckets.size) data.filter.measure == Measure.HOUSEHOLD -> stringResource(R.string.analytics_measure_household) @@ -192,7 +195,7 @@ private fun Ready(content: AnalyticsContent.Ready, events: AnalyticsEvents, open private fun OverTime(content: AnalyticsContent.Ready, onBucket: (String) -> Unit) { val data = content.data val groups = chartGroups(data, content.categories, content.accounts) - val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage()) + val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage(), long = true) val highest = data.buckets.maxByOrNull { it.totalOre } val lowest = data.buckets.minByOrNull { it.totalOre } val summary = if (highest != null && lowest != null) { diff --git a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt index 8463ede..11d229d 100644 --- a/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt +++ b/android/feature/analytics/src/main/kotlin/se/hagstedt/kvitt/feature/analytics/AnalyticsWords.kt @@ -31,14 +31,18 @@ internal fun presetLabel(preset: RangePreset): Int = when (preset) { internal fun measureLabel(measure: Measure): Int = if (measure == Measure.HOUSEHOLD) R.string.analytics_measure_household else R.string.analytics_measure_my_share -/** The range in a caption: whole months "maj–okt 2026" for a month preset, else days "24 aug.–17 okt.". */ -internal fun rangeText(start: String, end: String, filter: AnalyticsFilter, today: LocalDate, language: KvittLanguage): String { +/** + * The range in a title or caption: whole months "maj–okt 2026" for a month preset, else days: short "24 aug.–17 okt." + * or, with [long] (the total card caption and the chart summary, design frame S-11-analytics-week), written out with the + * year "24 augusti–17 oktober 2026" (`DateFormatter.dayRangeLong`). + */ +internal fun rangeText(start: String, end: String, filter: AnalyticsFilter, today: LocalDate, language: KvittLanguage, long: Boolean = false): String { val first = LocalDate.parse(start) val last = LocalDate.parse(end) - return if (filter.granularity == Granularity.MONTH && filter.preset != RangePreset.CUSTOM) { - DateFormatter.monthRange(first, last, language) - } else { - DateFormatter.dayRange(first, last, today, language) + return when { + filter.granularity == Granularity.MONTH && filter.preset != RangePreset.CUSTOM -> DateFormatter.monthRange(first, last, language) + long -> DateFormatter.dayRangeLong(first, last, language) + else -> DateFormatter.dayRange(first, last, today, language) } } diff --git a/android/feature/analytics/src/main/res/values-sv/strings.xml b/android/feature/analytics/src/main/res/values-sv/strings.xml index 1a0013d..3f16448 100644 --- a/android/feature/analytics/src/main/res/values-sv/strings.xml +++ b/android/feature/analytics/src/main/res/values-sv/strings.xml @@ -42,12 +42,8 @@ %d utgift %d utgifter - Indelning - Mått - Gruppera efter - Använd diff --git a/android/feature/analytics/src/main/res/values/strings.xml b/android/feature/analytics/src/main/res/values/strings.xml index 4fe6b65..d0028af 100644 --- a/android/feature/analytics/src/main/res/values/strings.xml +++ b/android/feature/analytics/src/main/res/values/strings.xml @@ -42,12 +42,8 @@ %d expense %d expenses - Interval - Measure - Group by - Apply diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt index ded84c5..b7cda55 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseDetailScreen.kt @@ -43,7 +43,6 @@ import se.hagstedt.kvitt.design.component.KvittIconImage import se.hagstedt.kvitt.design.component.KvittTopAppBar import se.hagstedt.kvitt.design.component.LocalKvittClock import se.hagstedt.kvitt.design.component.MoneyText -import se.hagstedt.kvitt.design.component.NeedsConnectionHint import se.hagstedt.kvitt.design.component.PersonAvatar import se.hagstedt.kvitt.design.component.ProvideHousehold import se.hagstedt.kvitt.design.component.SegmentedGroup @@ -141,12 +140,13 @@ private fun ColumnScope.Missing(state: ExpenseDetailUiState, actions: ExpenseDet EmptyState( title = stringResource(R.string.expense_detail_missing), body = stringResource(R.string.expense_detail_missing_body), + icon = KvittIcon.SyncProblem, action = stringResource(R.string.expense_detail_sync_now), actionIcon = KvittIcon.Sync, actionKind = ButtonKind.Tonal, + actionEnabled = state.online, onAction = { if (state.online && !state.syncing) actions.syncNow() }, ) - if (!state.online) NeedsConnectionHint(Modifier.align(Alignment.CenterHorizontally)) } @Composable @@ -256,7 +256,7 @@ private fun modeLabel(detail: ExpenseDetailState, me: User, partner: User): Stri SplitMode.EXACT -> splitModeLabel(SplitMode.EXACT, "") SplitMode.FULL -> { val bearer = detail.shares.firstOrNull { it.shareOre > 0 }?.userId - if (bearer == me.id) stringResource(R.string.expense_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName) + if (bearer == me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName) } } } diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt index 1d68ee9..183b098 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorScreen.kt @@ -86,6 +86,7 @@ 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.splitModeLabel +import se.hagstedt.kvitt.design.component.viewerPercentOf import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.design.format.AmountKey import se.hagstedt.kvitt.design.icon.KvittIcon @@ -354,9 +355,9 @@ internal fun EditorDialog(state: ExpenseEditorState, actions: ExpenseEditorActio val confirm = stringResource(DesignR.string.common_discard) val dismiss = stringResource(DesignR.string.common_keep_editing) if (inline) { - ConfirmDialogContent(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true) + ConfirmDialogContent(KvittIcon.Delete, title, null, confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true) } else { - ConfirmDialog(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true) + ConfirmDialog(KvittIcon.Delete, title, null, confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true) } } EditorOverlay.Delete -> DeleteExpenseDialog(actions::confirmDelete, actions::dismissOverlay, inline) @@ -399,7 +400,7 @@ private fun LocalizedDatePicker(date: String, today: String, actions: ExpenseEdi ) val confirm: @Composable () -> Unit = { TextButton({ picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it / DAY_MILLIS).toString()) } ?: actions.dismissOverlay() }) { - Text(stringResource(android.R.string.ok)) + Text(stringResource(DesignR.string.common_ok)) } } val dismiss: @Composable () -> Unit = { TextButton(actions::dismissOverlay) { Text(stringResource(DesignR.string.common_cancel)) } } @@ -426,8 +427,8 @@ private fun ErrorLine(text: String) { /** The viewer's percent of the account's default ratio (bp in `Account.defaultShares`, §14.2.4). */ private fun accountViewerPercent(state: ExpenseEditorState): Int { val me = state.me ?: return HALF - val bp = state.account?.defaultShares?.get(me.id) ?: return HALF - return (bp / PERCENT_HUNDREDTHS).toInt() + val account = state.account ?: return HALF + return viewerPercentOf(account.defaultShares, me.id) } @Composable @@ -442,14 +443,14 @@ private fun modeChipLabel(state: ExpenseEditorState): String { SplitMode.RATIO -> { val language = kvittLanguage() val viewer = form.ratioViewer.ore ?: return splitModeLabel(form.mode, "") - val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language)) + val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.percent(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language)) splitModeLabel(form.mode, "") + " " + ratio } SplitMode.EXACT -> splitModeLabel(form.mode, "") SplitMode.FULL -> { val partner = state.partner when { - form.fullOn == state.me?.id -> stringResource(R.string.expense_split_full_on_you) + form.fullOn == state.me?.id -> stringResource(DesignR.string.editor_split_full_on_you) partner == null -> splitModeLabel(form.mode, "") else -> stringResource(DesignR.string.editor_split_full_on, partner.displayName) } diff --git a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt index 4b71b02..3f0350d 100644 --- a/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt +++ b/android/feature/expense/src/main/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModel.kt @@ -197,16 +197,16 @@ class ExpenseEditorViewModel @Inject constructor( id = draft.id, accountId = draft.accountId, categoryId = draft.categoryId, - amount = draft.amountOre?.let(::moneyInput) ?: AmountInput(), + amount = draft.amountOre?.let(AmountInput::of) ?: AmountInput(), description = draft.description, notes = draft.notes, date = draft.date, payer = draft.payer, mode = draft.mode, - ratioViewer = viewerBp?.let { AmountInput.of(it.toLong()) } ?: AmountInput(), - ratioPartner = viewerBp?.let { AmountInput.of(BP_TOTAL - it) } ?: AmountInput(), + ratioViewer = viewerBp?.let { AmountInput.percent(it.toLong()) } ?: AmountInput(), + ratioPartner = viewerBp?.let { AmountInput.percent(BP_TOTAL - it) } ?: AmountInput(), exactTyped = me, - exactInput = exactMine?.let(::moneyInput) ?: AmountInput(), + exactInput = exactMine?.let(AmountInput::of) ?: AmountInput(), fullOn = draft.fullOn ?: if (draft.payer == me) partner else me, ) ui.update { @@ -255,8 +255,9 @@ class ExpenseEditorViewModel @Inject constructor( val choices = options.accounts.map { a -> AccountChoice(summaries[a.id] ?: AccountSummary(a, 0, 0), colorIndex[a.id] ?: 0) } val account = form.accountId?.let { id -> options.accounts.firstOrNull { it.id == id } ?: env.all.firstOrNull { it.id == id } } val active = options.categories.filter { !it.archived }.sortedBy { it.sortOrder } - // D-059/D-060: the account's used categories, filled up to five from sort_order; the selected one takes the fifth place. - val frequent = u.frequent.ifEmpty { active.take(QUICK_ROW).map { it.id } } + // D-059/D-060: the account's used categories as the repository gives them; quickRowCategories fills up to five + // from sort_order and puts the selected one fifth. + val frequent = u.frequent return ExpenseEditorState( ready = true, isEdit = form.id != null, @@ -306,7 +307,7 @@ class ExpenseEditorViewModel @Inject constructor( KeypadTarget.RatioViewer, KeypadTarget.RatioPartner -> { val typed = if (target == KeypadTarget.RatioViewer) form.ratioViewer else form.ratioPartner val next = typed.press(key, AmountInput.MAX_PERCENT_HUNDREDTHS) ?: return false - val other = next.ore?.let { AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput() + val other = next.ore?.let { AmountInput.percent(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput() editForm { if (target == KeypadTarget.RatioViewer) it.copy(ratioViewer = next, ratioPartner = other) else it.copy(ratioPartner = next, ratioViewer = other) } @@ -375,7 +376,7 @@ class ExpenseEditorViewModel @Inject constructor( val form = u.form ?: return@update u val next = when (mode) { SplitMode.DEFAULT -> form.copy(mode = mode) - SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.of(viewerBp), ratioPartner = AmountInput.of(BP_TOTAL - viewerBp)) + SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.percent(viewerBp), ratioPartner = AmountInput.percent(BP_TOTAL - viewerBp)) SplitMode.EXACT -> form.copy(mode = mode, exactTyped = me, exactInput = AmountInput()) SplitMode.FULL -> form.copy(mode = mode, fullOn = if (form.payer == me) partner else me) } @@ -394,7 +395,7 @@ class ExpenseEditorViewModel @Inject constructor( /** Ratio slider and presets: whole percent for the viewer. */ override fun onViewerPercent(percent: Int) { val bp = percent.coerceIn(0, PERCENT) * PERCENT.toLong() - editForm { it.copy(ratioViewer = AmountInput.of(bp), ratioPartner = AmountInput.of(BP_TOTAL - bp)) } + editForm { it.copy(ratioViewer = AmountInput.percent(bp), ratioPartner = AmountInput.percent(BP_TOTAL - bp)) } clearError(FIELD_SPLIT) } @@ -525,15 +526,8 @@ class ExpenseEditorViewModel @Inject constructor( private const val BP_TOTAL = 10_000L private const val HALF_BP = 5000 private const val PERCENT = 100 - private const val QUICK_ROW = 5 private val SPLIT_TARGETS = setOf(KeypadTarget.RatioViewer, KeypadTarget.RatioPartner, KeypadTarget.Exact) - /** A stored amount as editable text with both decimals when it has öre: 124 760 -> "1247.60", 41 200 -> "412". */ - fun moneyInput(ore: Long): AmountInput { - val cents = ore % ORE_PER_KRONA - return AmountInput(if (cents == 0L) (ore / ORE_PER_KRONA).toString() else "${ore / ORE_PER_KRONA}." + cents.toString().padStart(2, '0')) - } - private const val ORE_PER_KRONA = 100L /** The draft of §14.2.4 from the form: exact derives the other share as amount minus the typed one (AC-8.7). */ diff --git a/android/feature/expense/src/main/res/values-sv/strings.xml b/android/feature/expense/src/main/res/values-sv/strings.xml index 3447672..458602e 100644 --- a/android/feature/expense/src/main/res/values-sv/strings.xml +++ b/android/feature/expense/src/main/res/values-sv/strings.xml @@ -19,7 +19,5 @@ Den räknas inte längre i saldon eller statistik. Utgiften finns inte på telefonen än Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den. - Synka nu - Allt på dig diff --git a/android/feature/expense/src/main/res/values/strings.xml b/android/feature/expense/src/main/res/values/strings.xml index 39fa2bb..bd06ac3 100644 --- a/android/feature/expense/src/main/res/values/strings.xml +++ b/android/feature/expense/src/main/res/values/strings.xml @@ -19,7 +19,5 @@ It no longer counts in balances or analytics. This expense is not on the phone yet It may have just been added on another phone. Sync to fetch it. - Sync now - All on you diff --git a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt index 5753191..43654de 100644 --- a/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt +++ b/android/feature/expense/src/test/kotlin/se/hagstedt/kvitt/feature/expense/ExpenseEditorViewModelTest.kt @@ -17,6 +17,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.design.format.AmountKey import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeScenario @@ -269,8 +270,9 @@ class ExpenseEditorViewModelTest { fun editedAmounts_keepBothDecimals() = runTest(dispatcher) { val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value)) assertEquals("1247.60", vm.state.value.form?.amount?.raw) - assertEquals("412", ExpenseEditorViewModel.moneyInput(41_200).raw) - assertEquals("0.05", ExpenseEditorViewModel.moneyInput(5).raw) + // The feature's own moneyInput is gone: :core:design's AmountInput.of restores money with both decimals (M6b). + assertEquals("412.00", AmountInput.of(41_200).raw) + assertEquals("0.05", AmountInput.of(5).raw) } @Test diff --git a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt index 132a141..d42a247 100644 --- a/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt +++ b/android/feature/home/src/main/kotlin/se/hagstedt/kvitt/feature/home/HomeScreen.kt @@ -53,6 +53,7 @@ import se.hagstedt.kvitt.design.component.Skeleton import se.hagstedt.kvitt.design.component.SyncStatusPill import se.hagstedt.kvitt.design.component.UpdateBanner import se.hagstedt.kvitt.design.component.kvittLanguage +import se.hagstedt.kvitt.design.component.styledStringResource import se.hagstedt.kvitt.design.format.DateFormatter import se.hagstedt.kvitt.design.icon.KvittIcon import se.hagstedt.kvitt.design.theme.KvittDimens @@ -174,7 +175,7 @@ private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padd item(key = "hero") { BalanceHero(HeroState.Loading, ui.home.me, words, actions.onSettleUp) } item(key = "pill") { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - Skeleton(spacing.s1, KvittDimens.pillMinHeight, Modifier.fillMaxWidth(SKELETON_PILL_FRACTION), pill = true) + Skeleton(KvittDimens.skeletonPillWidth, KvittDimens.pillMinHeight, pill = true) } } item(key = "header") { SectionHeader(stringResource(R.string.home_accounts)) } @@ -234,7 +235,6 @@ private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padd } private const val SKELETON_CARDS = 3 -private const val SKELETON_PILL_FRACTION = 0.5f @Composable private fun UpdateBannerItem(ui: HomeUi.Ready, actions: HomeActions) { @@ -252,8 +252,7 @@ private fun HomeAccountCard(summary: AccountSummary, partner: User, colorIndex: private fun expensesLine(count: Int): String { val today = LocalKvittClock.current.today val period = today.year.toString() + "-" + today.month.ordinal.plus(1).toString().padStart(2, '0') - // monthYear is "oktober 2026" / "October 2026"; running text needs the month alone. - val month = DateFormatter.monthYear(period, kvittLanguage()).substringBeforeLast(' ') + val month = DateFormatter.monthName(period, kvittLanguage()) return if (count > 0) pluralStringResource(R.plurals.home_account_expenses_month, count, count, month) else stringResource(R.string.home_account_no_expenses_month, month) } @@ -263,8 +262,8 @@ private fun WelcomeCard(me: User, partner: User) { KvittCard(Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap)) { Box { - PersonAvatar(me, KvittDimens.accountTile) - PersonAvatar(partner, KvittDimens.accountTile, Modifier.padding(start = KvittDimens.accountTile - KvittTheme.spacing.s2)) + PersonAvatar(me, KvittDimens.welcomeAvatar) + PersonAvatar(partner, KvittDimens.welcomeAvatar, Modifier.padding(start = KvittDimens.welcomeAvatar - KvittDimens.welcomeAvatarOverlap)) } Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { Text(stringResource(R.string.home_empty_welcome, partner.displayName), style = MaterialTheme.typography.titleMedium) @@ -280,7 +279,8 @@ private fun InstallDialog(actions: HomeActions) { ConfirmDialog( icon = KvittIcon.InstallMobile, title = stringResource(DesignR.string.update_permission_title), - body = stringResource(DesignR.string.update_permission_body), + body = null, + styledBody = styledStringResource(DesignR.string.update_permission_body), confirm = stringResource(DesignR.string.common_continue), dismiss = stringResource(DesignR.string.update_later), onConfirm = actions.onInstallContinue, diff --git a/android/feature/home/src/main/res/values-sv/strings.xml b/android/feature/home/src/main/res/values-sv/strings.xml index a0b5586..7b85a62 100644 --- a/android/feature/home/src/main/res/values-sv/strings.xml +++ b/android/feature/home/src/main/res/values-sv/strings.xml @@ -57,8 +57,6 @@ Datum och tid Senaste försöket nekades: klockan - utfälld - hopfälld diff --git a/android/feature/home/src/main/res/values/strings.xml b/android/feature/home/src/main/res/values/strings.xml index 723374d..0c757cf 100644 --- a/android/feature/home/src/main/res/values/strings.xml +++ b/android/feature/home/src/main/res/values/strings.xml @@ -57,8 +57,6 @@ Date & time Last attempt refused: the clock - expanded - collapsed diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingDimens.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingDimens.kt deleted file mode 100644 index 1330db6..0000000 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingDimens.kt +++ /dev/null @@ -1,41 +0,0 @@ -package se.hagstedt.kvitt.feature.onboarding - -import androidx.compose.ui.unit.dp - -/** - * Measures of design-spec §4 S-1, S-2 and S-18 that `:core:design`'s `KvittDimens` does not name yet. Kept in this one - * file so they move to `KvittDimens` in one step (contract request in the M7a report); nothing else in this module - * writes a size. - */ -internal object OnboardingDimens { - // S-1 brand block: padding 28 / 0 / 12, app mark 88 with `receipt_long` 44. - val brandTop = 28.dp - val brandBottom = 12.dp - val appMark = 88.dp - val appMarkIcon = 44.dp - - // S-1 column gap 14; bottom bar padding 12 / 16; progress indicator in the button 20 (stroke 2.5). - val columnGap = 14.dp - val bottomBarVertical = 12.dp - val bottomBarHorizontal = 16.dp - val buttonProgress = 20.dp - val buttonProgressStroke = 2.5.dp - - // S-1 help line icon 20. - val helpIcon = 20.dp - - // S-2 intro padding 24 / 4 / 4; card padding 22 / 20, gap 14; avatar badge 40 with icon 20 and a 3 dp card ring. - val introTop = 24.dp - val introSide = 4.dp - val introBottom = 4.dp - val cardVertical = 22.dp - val cardHorizontal = 20.dp - val avatarBadge = 40.dp - val avatarBadgeIcon = 20.dp - val avatarBadgeRing = 3.dp - - // S-18: padding top 80, gap 14, tile 112 with `phonelink_erase` 52. - val removedTop = 80.dp - val removedTile = 112.dp - val removedIcon = 52.dp -} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingParts.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingParts.kt index 4d903b7..cdb99ec 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingParts.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingParts.kt @@ -1,27 +1,11 @@ package se.hagstedt.kvitt.feature.onboarding -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -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.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -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.text.style.TextAlign -import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.component.ButtonKind import se.hagstedt.kvitt.design.component.ButtonSize import se.hagstedt.kvitt.design.component.KvittButton @@ -35,40 +19,15 @@ internal fun BottomActionBar(content: @Composable () -> Unit) { Modifier .fillMaxWidth() .navigationBarsPadding() - .padding(horizontal = OnboardingDimens.bottomBarHorizontal, vertical = OnboardingDimens.bottomBarVertical), + .padding(horizontal = KvittDimens.bottomBarHorizontal, vertical = KvittDimens.bottomBarVertical), ) { content() } } -/** The full-width filled medium button of the bottom bars. */ -@Composable -internal fun BottomButton(text: String, onClick: () -> Unit, icon: KvittIcon?, enabled: Boolean = true) { - KvittButton(text, onClick, Modifier.fillMaxWidth(), kind = ButtonKind.Filled, size = ButtonSize.Medium, icon = icon, enabled = enabled) -} - /** - * S-1's "Para ihop" while pairing (design-spec S-1 "loading"): the filled medium button keeps its `primary` fill and - * shows a 20 dp progress indicator in `on-primary` with "Parar ihop…"; it is not clickable. `KvittButton` has no - * progress form (contract request in the M7a report), so this draws the same button with Material's `Button`. + * The full-width filled medium button of the bottom bars. [loading] is S-1's "Parar ihop…" (design-spec S-1 + * "loading"): the fill kept, a 20 dp progress indicator in `on-primary`, not clickable ([KvittButton]'s loading form). */ @Composable -internal fun ProgressBottomButton(text: String) { - val scheme = MaterialTheme.colorScheme - Button( - onClick = {}, - modifier = Modifier.fillMaxWidth().heightIn(min = KvittDimens.buttonMedium), - enabled = true, - shape = CircleShape, - colors = ButtonDefaults.buttonColors(containerColor = scheme.primary, contentColor = scheme.onPrimary), - contentPadding = PaddingValues(horizontal = KvittDimens.buttonMediumHorizontal), - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { - CircularProgressIndicator( - Modifier.size(OnboardingDimens.buttonProgress), - color = scheme.onPrimary, - strokeWidth = OnboardingDimens.buttonProgressStroke, - ) - Spacer(Modifier.width(KvittTheme.spacing.s2)) - Text(text, style = KvittTheme.typography.buttonMedium, textAlign = TextAlign.Center, maxLines = 2) - } - } +internal fun BottomButton(text: String, onClick: () -> Unit, icon: KvittIcon?, enabled: Boolean = true, loading: Boolean = false) { + KvittButton(text, onClick, Modifier.fillMaxWidth(), kind = ButtonKind.Filled, size = ButtonSize.Medium, icon = icon, enabled = enabled, loading = loading) } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt index 61003a6..5834d3b 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt @@ -130,7 +130,7 @@ fun PairingContent( bottomBar = { BottomActionBar { if (state.pairing) { - ProgressBottomButton(stringResource(R.string.onboarding_pairing)) + BottomButton(stringResource(R.string.onboarding_pairing), {}, KvittIcon.Link, loading = true) } else { BottomButton(stringResource(R.string.onboarding_pair), actions.onPair, KvittIcon.Link, enabled = state.canPair) } @@ -143,7 +143,7 @@ fun PairingContent( .padding(padding) .verticalScroll(rememberScrollState()) .padding(horizontal = KvittTheme.spacing.gutter), - verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap), + verticalArrangement = Arrangement.spacedBy(KvittDimens.onboardingGap), ) { Brand(state.mode) val codeError = when (state.error) { @@ -160,6 +160,8 @@ fun PairingContent( modifier = Modifier.announced(codeError), error = codeError, enabled = !state.pairing, + placeholder = stringResource(R.string.onboarding_code_placeholder), + imeAction = if (state.nameRequired) ImeAction.Next else ImeAction.Done, ) if (state.nameRequired) { KvittTextField( @@ -211,15 +213,15 @@ private fun Modifier.announced(error: String?): Modifier = private fun Brand(mode: PairingMode) { val scheme = MaterialTheme.colorScheme Column( - Modifier.fillMaxWidth().padding(top = OnboardingDimens.brandTop, bottom = OnboardingDimens.brandBottom), + Modifier.fillMaxWidth().padding(top = KvittDimens.brandTop, bottom = KvittDimens.brandBottom), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), ) { Box( - Modifier.size(OnboardingDimens.appMark).background(scheme.primary, KvittTheme.shapes.avatarLarge), + Modifier.size(KvittDimens.appMark).background(scheme.primary, KvittTheme.shapes.avatarLarge), contentAlignment = Alignment.Center, ) { - KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = OnboardingDimens.appMarkIcon, tint = scheme.onPrimary) + KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.appMarkIcon, tint = scheme.onPrimary) } Text(stringResource(DesignR.string.common_wordmark), style = MaterialTheme.typography.displaySmall, color = scheme.primary) if (mode == PairingMode.Again) { @@ -252,7 +254,7 @@ private fun HelpLine() { Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), ) { - KvittIconImage(KvittIcon.Info, null, size = OnboardingDimens.helpIcon, tint = muted) + KvittIconImage(KvittIcon.Info, null, size = KvittDimens.helpIcon, tint = muted) Text(stringResource(R.string.onboarding_code_help_generic), style = MaterialTheme.typography.bodyMedium, color = muted) } } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt index 85f9fb8..a5df195 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt @@ -169,10 +169,10 @@ private fun Intro(owner: User) { val ownerName = owner.displayName Column( Modifier.fillMaxWidth().padding( - start = OnboardingDimens.introSide, - end = OnboardingDimens.introSide, - top = OnboardingDimens.introTop, - bottom = OnboardingDimens.introBottom, + start = KvittDimens.profileIntroSide, + end = KvittDimens.profileIntroSide, + top = KvittDimens.profileIntroTop, + bottom = KvittDimens.profileIntroBottom, ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), @@ -197,9 +197,9 @@ private fun ProfileCard(state: ProfileStepUiState, actions: ProfileStepActions) val me = state.me ?: return KvittCard( Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = OnboardingDimens.cardHorizontal, vertical = OnboardingDimens.cardVertical), + contentPadding = PaddingValues(horizontal = KvittDimens.profileCardHorizontal, vertical = KvittDimens.profileCardVertical), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap), + verticalArrangement = Arrangement.spacedBy(KvittDimens.onboardingGap), ) { Box { PersonAvatar( @@ -212,14 +212,14 @@ private fun ProfileCard(state: ProfileStepUiState, actions: ProfileStepActions) Box( Modifier .align(Alignment.BottomEnd) - .size(OnboardingDimens.avatarBadge) - .border(OnboardingDimens.avatarBadgeRing, KvittTheme.colors.card, CircleShape) - .padding(OnboardingDimens.avatarBadgeRing) + .size(KvittDimens.avatarBadge) + .border(KvittDimens.avatarBadgeRing, KvittTheme.colors.card, CircleShape) + .padding(KvittDimens.avatarBadgeRing) .background(MaterialTheme.colorScheme.primary, CircleShape) .clearAndSetSemantics {}, contentAlignment = Alignment.Center, ) { - KvittIconImage(KvittIcon.AddAPhoto, null, size = OnboardingDimens.avatarBadgeIcon, tint = MaterialTheme.colorScheme.onPrimary) + KvittIconImage(KvittIcon.AddAPhoto, null, size = KvittDimens.avatarBadgeIcon, tint = MaterialTheme.colorScheme.onPrimary) } } Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) { diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedScreen.kt index 7f8533c..486787a 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedScreen.kt @@ -26,6 +26,7 @@ import se.hagstedt.kvitt.design.KvittTheme import se.hagstedt.kvitt.design.component.KvittIconImage import se.hagstedt.kvitt.design.component.KvittScaffold import se.hagstedt.kvitt.design.icon.KvittIcon +import se.hagstedt.kvitt.design.theme.KvittDimens /** S-18 Device removed (design-spec S-18, AC-3.3). [onPairAgain] opens S-1 after the revocation is acknowledged. */ @Composable @@ -57,15 +58,15 @@ fun RemovedContent(onPairAgain: () -> Unit, modifier: Modifier = Modifier) { .fillMaxSize() .padding(padding) .verticalScroll(rememberScrollState()) - .padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, top = OnboardingDimens.removedTop), + .padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, top = KvittDimens.removedTop), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap), + verticalArrangement = Arrangement.spacedBy(KvittDimens.onboardingGap), ) { Box( - Modifier.size(OnboardingDimens.removedTile).background(scheme.errorContainer, KvittTheme.shapes.illustration), + Modifier.size(KvittDimens.removedTile).background(scheme.errorContainer, KvittTheme.shapes.illustration), contentAlignment = Alignment.Center, ) { - KvittIconImage(KvittIcon.PhonelinkErase, null, size = OnboardingDimens.removedIcon, tint = scheme.onErrorContainer) + KvittIconImage(KvittIcon.PhonelinkErase, null, size = KvittDimens.removedIcon, tint = scheme.onErrorContainer) } Text( stringResource(R.string.onboarding_removed_title), diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt index 265ea9d..055550c 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/about/AboutScreen.kt @@ -93,8 +93,8 @@ fun AboutContent(state: AboutScreenState?, actions: AboutActions, updateCheckAva verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap), ) { Column(Modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s4), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) { - Box(Modifier.size(KvittTheme.spacing.s12 * 2).background(MaterialTheme.colorScheme.primary, KvittTheme.shapes.avatarLarge).clearAndSetSemantics { }, contentAlignment = Alignment.Center) { - KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittTheme.spacing.s12, tint = MaterialTheme.colorScheme.onPrimary) + Box(Modifier.size(KvittDimens.aboutAppMark).background(MaterialTheme.colorScheme.primary, KvittTheme.shapes.avatarLarge).clearAndSetSemantics { }, contentAlignment = Alignment.Center) { + KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.aboutAppMarkIcon, tint = MaterialTheme.colorScheme.onPrimary) } Text(stringResource(R.string.settings_about_version, about.versionName, about.versionCode), style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center) Text(stringResource(DesignR.string.common_tagline), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt index 77bf4fd..34442df 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorScreen.kt @@ -246,8 +246,8 @@ private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActi } SplitMode.RATIO -> { val viewerTyped = form.ratioTyped == RatioField.Viewer - val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.of(form.viewerBp.toLong()).display(language) - val partnerText = if (viewerTyped) AmountInput.of((10_000 - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language) + val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.percent(form.viewerBp.toLong()).display(language) + val partnerText = if (viewerTyped) AmountInput.percent((10_000 - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language) val focused = when (form.focus) { EditorFocus.RatioViewer -> RatioField.Viewer EditorFocus.RatioPartner -> RatioField.Partner @@ -370,7 +370,7 @@ private fun EditorDialogs(state: RecurringEditorState, actions: RecurringEditorA EditorDialog.Discard -> ConfirmDialog( icon = KvittIcon.Close, title = stringResource(DesignR.string.common_discard_title), - body = "", + body = null, confirm = stringResource(DesignR.string.common_discard), dismiss = stringResource(DesignR.string.common_keep_editing), onConfirm = actions.onDiscard, diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt index c284e3e..ad65fdc 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringEditorViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.launch import se.hagstedt.kvitt.design.component.AccountChoice import se.hagstedt.kvitt.design.component.RatioField import se.hagstedt.kvitt.design.component.quickRowCategories +import se.hagstedt.kvitt.design.component.viewerPercentOf import se.hagstedt.kvitt.design.format.AmountInput import se.hagstedt.kvitt.design.format.AmountKey import se.hagstedt.kvitt.feature.settings.common.UiMessage @@ -64,7 +65,7 @@ data class RecurringEditorState( val canSave: Boolean get() = online && !busy && changed && valid && !errors.any /** The account's default ratio for "Kontots standard 60/40", viewer first. */ - val viewerDefaultPercent: Int get() = (account?.defaultShares?.get(options.me.id) ?: BP_TOTAL / 2) / 100 + val viewerDefaultPercent: Int get() = viewerPercentOf(account?.defaultShares.orEmpty(), options.me.id) val dayClampHint: Boolean get() = (form.dayOfMonth ?: 0) >= 29 val resumeHint: Boolean get() = original?.paused == true && !form.paused } diff --git a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt index 99bf8bf..33df4d3 100644 --- a/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt +++ b/android/feature/settings/src/main/kotlin/se/hagstedt/kvitt/feature/settings/recurring/RecurringForm.kt @@ -139,7 +139,7 @@ data class RecurringForm( categoryPicked = true, payer = d.payer, mode = d.mode, - ratioInput = AmountInput.of((viewerBp ?: BP_TOTAL / 2).toLong()), + ratioInput = AmountInput.percent((viewerBp ?: BP_TOTAL / 2).toLong()), exactTyped = typed, exactInput = d.exactOre?.get(typed)?.let { AmountInput.of(it) } ?: AmountInput(), fullOn = d.fullOn ?: me, diff --git a/android/feature/settings/src/main/res/values-sv/strings.xml b/android/feature/settings/src/main/res/values-sv/strings.xml index 90045f3..1cdd712 100644 --- a/android/feature/settings/src/main/res/values-sv/strings.xml +++ b/android/feature/settings/src/main/res/values-sv/strings.xml @@ -1,6 +1,6 @@ - + Kräver anslutning. Profilen kan bara ändras när telefonen är online. Inställningar @@ -179,16 +179,10 @@ Serverversion Okänd Licenser för öppen källkod - %1$s arkiverades - %1$s återställdes - Inga - utfälld - hopfälld - Partner saknas diff --git a/android/feature/settings/src/main/res/values/strings.xml b/android/feature/settings/src/main/res/values/strings.xml index 8170779..4f8659d 100644 --- a/android/feature/settings/src/main/res/values/strings.xml +++ b/android/feature/settings/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ - + Needs connection. Your profile can only be changed online. Settings @@ -179,16 +179,10 @@ Server version Unknown Open-source licences - %1$s was archived - %1$s was restored - None - expanded - collapsed - Partner has not joined diff --git a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt index 704aaaa..5c63e3a 100644 --- a/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt +++ b/android/feature/settings/src/test/kotlin/se/hagstedt/kvitt/feature/settings/screenshot/RecurringScreenshotTest.kt @@ -97,7 +97,7 @@ class RecurringScreenshotTest(private val state: String) { editor("edit_exact_over", SettingsScenarios.loaded, SettingsScenarios.netflix) { _, vm -> vm.toggleSplit() vm.focus(EditorFocus.Exact) - repeat(4) { vm.onKey(AmountKey.Delete) } + vm.onKey(AmountKey.Clear) vm.type("200") }, editor("edit_full", SettingsScenarios.loaded, SettingsScenarios.spotify) { _, vm -> vm.toggleSplit() }, diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleScreen.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleScreen.kt index 5fe0d90..7769d04 100644 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleScreen.kt +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettleScreen.kt @@ -125,7 +125,7 @@ fun SettleContent(state: SettleUiState, actions: SettleActions, modifier: Modifi ConfirmDialogContent( icon = KvittIcon.Delete, title = stringResource(DesignR.string.common_discard_title), - body = "", + body = null, confirm = stringResource(DesignR.string.common_discard), dismiss = stringResource(DesignR.string.common_keep_editing), onConfirm = actions::onDiscard, @@ -457,7 +457,7 @@ private fun SettleDatePicker(isoDate: String, actions: SettleActions) { DatePickerDialog( onDismissRequest = actions::onDismissDialog, confirmButton = { - KvittButton(resources.getString(DesignR.string.common_save), { + KvittButton(resources.getString(DesignR.string.common_ok), { picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it.floorDiv(MILLIS_PER_DAY)).toString()) } ?: actions.onDismissDialog() }, kind = ButtonKind.Text) }, diff --git a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt index 7cc3172..9e3ff12 100644 --- a/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt +++ b/android/feature/settle/src/main/kotlin/se/hagstedt/kvitt/feature/settle/SettlementDetailScreen.kt @@ -119,6 +119,7 @@ fun SettlementDetailContent( detail == null -> EmptyState( title = stringResource(R.string.settle_detail_missing), body = stringResource(R.string.settle_detail_missing_body), + icon = KvittIcon.SyncProblem, action = stringResource(R.string.settle_detail_sync_now), actionIcon = KvittIcon.Sync, actionKind = ButtonKind.Tonal, @@ -210,13 +211,13 @@ private fun Hero(detail: SettlementDetailState, modifier: Modifier) { KvittCard( modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken }, color = KvittTheme.colors.settlementHero, - contentPadding = PaddingValues(horizontal = KvittTheme.spacing.s5, vertical = KvittTheme.spacing.s5), + contentPadding = PaddingValues(horizontal = KvittDimens.settlementHeroHorizontal, vertical = KvittDimens.settlementHeroVertical), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), horizontalAlignment = Alignment.CenterHorizontally, ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) { PersonAvatar(detail.from, KvittDimens.avatarSettings, shape = AvatarShape.Large) - KvittIconImage(KvittIcon.ArrowForward, null, size = KvittDimens.accountHeaderSettledIcon, tint = MaterialTheme.colorScheme.onSurfaceVariant) + KvittIconImage(KvittIcon.ArrowForward, null, size = KvittDimens.settlementArrow, tint = MaterialTheme.colorScheme.onSurfaceVariant) PersonAvatar(detail.to, KvittDimens.avatarSettings, shape = AvatarShape.Large) } Text(text, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center) diff --git a/android/feature/settle/src/main/res/values-sv/strings.xml b/android/feature/settle/src/main/res/values-sv/strings.xml index 4207301..43f2047 100644 --- a/android/feature/settle/src/main/res/values-sv/strings.xml +++ b/android/feature/settle/src/main/res/values-sv/strings.xml @@ -1,6 +1,5 @@ - + Gör upp Ändra betalning diff --git a/android/feature/settle/src/main/res/values/strings.xml b/android/feature/settle/src/main/res/values/strings.xml index 2cf0a0a..9374bb9 100644 --- a/android/feature/settle/src/main/res/values/strings.xml +++ b/android/feature/settle/src/main/res/values/strings.xml @@ -1,6 +1,5 @@ - + Settle up Edit payment diff --git a/docs/ux/strings.csv b/docs/ux/strings.csv index 6f05589..dcc1511 100644 --- a/docs/ux/strings.csv +++ b/docs/ux/strings.csv @@ -10,6 +10,8 @@ common_continue,Continue,Fortsätt,module=core:design common_name,Name,Namn,"module=core:design; name field label in S-6, S-14, S-15" common_name_required,Enter a name,Skriv ett namn,module=core:design; required on a name field common_retry,Try again,Försök igen,module=core:design +common_ok,OK,OK,"module=core:design; confirm button of the date pickers (S-7 Datum, S-9 date); M6b" +common_undo,Undo,Ångra,"module=core:design; snackbar action that undoes the last action (account archived, S-5); M6b" common_more_options,More options,Fler alternativ,module=core:design; TalkBack label of the overflow icon common_you,You,Du,"module=core:design; the viewer as a name, sentence start" common_you_lower,you,du,module=core:design; the viewer inside a sentence @@ -102,6 +104,7 @@ editor_split_mode_ratio,Custom ratio,Egen fördelning,module=core:design editor_split_mode_exact,Exact amounts,Exakta belopp,module=core:design editor_split_mode_full,All on one person,Allt på en person,module=core:design editor_split_full_on,All on %1$s,Allt på %1$s,module=core:design +editor_split_full_on_you,All on you,Allt på dig,"module=core:design; full split borne by the viewer (object form: ""dig"", not ""du""): split chip, full-split picker, S-8 split fact; M6b, was expense_split_full_on_you (M7c)" editor_split_full_question,Who pays the whole amount?,Vem står för hela beloppet?,module=core:design; full mode prompt editor_split_exact_computed,calculated,räknas ut,module=core:design; trailing text of the other person's read-only field in exact mode editor_split_exact_of,%1$s %2$s of %3$s,%1$s %2$s av %3$s,"module=core:design; exact-mode error box: name, share, amount" @@ -321,6 +324,8 @@ home_sync_status_clock_warning,Your phone's clock is wrong. Fix it and sync agai home_sync_status_clock_body,The clock is more than 10 minutes ahead of the server. Your changes stay on the phone until it is fixed.,Klockan ligger mer än 10 minuter före servern. Dina ändringar sparas på telefonen tills dess.,module=home; §14.4.6 clock_skew home_sync_status_open_date_settings,Date & time,Datum och tid,module=home; opens the system date settings home_sync_status_last_attempt_clock,Last attempt refused: the clock,Senaste försöket nekades: klockan,module=home +home_archived_expanded,expanded,utfälld,module=home; TalkBack state of the Arkiverade (n) row (design-spec §3.11 CollapsibleRow); M7b +home_archived_collapsed,collapsed,hopfälld,module=home; TalkBack state of the Arkiverade (n) row; M7b account_default_split,%1$s · %2$s,%1$s · %2$s,"module=account; ""50 / 50 · Jacob och Anna""; %1$s ratio, %2$s names with common_and" account_default_split_spoken,Default split: %1$s,Standarddelning: %1$s,module=account; TalkBack; %1$s editor_ratio_spoken account_settle_this,Settle this account,Gör upp det här kontot,module=account @@ -361,6 +366,7 @@ expense_detail_deleted_banner,%1$s deleted this expense · %2$s,%1$s tog bort de expense_detail_deleted_body,It no longer counts in balances or analytics.,Den räknas inte längre i saldon eller statistik.,module=expense; under the deleted banner expense_detail_missing,This expense is not on the phone yet,Utgiften finns inte på telefonen än,module=expense expense_detail_missing_body,It may have just been added on another phone. Sync to fetch it.,Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.,module=expense; S-8 opened from a notification before the sync +expense_detail_sync_now,Sync now,Synka nu,module=expense; S-8 missing state action; M7c settle_title,Settle up,Gör upp,module=settle settle_title_edit,Edit payment,Ändra betalning,module=settle settle_title_account,Settle %1$s,Gör upp %1$s,module=settle; per-account variant; %1$s account name @@ -402,6 +408,9 @@ settle_detail_deleted_banner,%1$s deleted this payment · %2$s,%1$s tog bort den settle_detail_deleted_body,It no longer counts in the balances.,Den räknas inte längre i saldona.,module=settle; under the deleted banner settle_detail_missing,This payment is not on the phone yet,Betalningen finns inte på telefonen än,module=settle; S-10 opened from a notification before the sync settle_detail_missing_body,It may have just been added on another phone. Sync to fetch it.,Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.,module=settle; as expense_detail_missing_body +settle_row_amount,Amount for %1$s,Belopp för %1$s,module=settle; TalkBack label of an allocation amount field; %1$s account name; M7d +settle_detail_delete,Delete payment,Ta bort betalning,module=settle; S-10 delete icon button (TalkBack); M7d +settle_detail_sync_now,Sync now,Synka nu,module=settle; S-10 missing state action; M7d analytics_title,Analytics,Statistik,module=analytics analytics_period,Period,Period,module=analytics analytics_period_spoken,Period: %1$s,Period: %1$s,module=analytics; TalkBack of the period chip @@ -439,6 +448,10 @@ analytics_drilldown_empty,No expenses any more,Inga utgifter längre,module=anal analytics_drilldown_title,"%1$s, %2$s","%1$s, %2$s","module=analytics; ""Mat & hushåll, oktober 2026""" analytics_drilldown_expenses:one,%d expense,%d utgift,module=analytics; plural one; S-12 total card subtitle analytics_drilldown_expenses:other,%d expenses,%d utgifter,module=analytics; plural other +analytics_granularity,Interval,Indelning,module=analytics; radio-group name of the Månad | Vecka toggle (design-spec §3.10); M7e +analytics_measure,Measure,Mått,module=analytics; radio-group name of the Hushållet | Min del toggle; M7e +analytics_group_by,Group by,Gruppera efter,module=analytics; radio-group name of the Kategori | Konto toggle; M7e +analytics_range_apply,Apply,Använd,module=analytics; confirm button of the custom range picker; M7e settings_profile_offline_banner,Needs connection. Your profile can only be changed online.,Kräver anslutning. Profilen kan bara ändras när telefonen är online.,module=settings settings_title,Settings,Inställningar,module=settings settings_role_admin,You · Admin,Du · Administratör,module=settings; S-13 own row when the viewer is the admin @@ -557,3 +570,9 @@ settings_about_server,Server,Server,module=settings settings_about_server_version,Server version,Serverversion,module=settings; label; value from /health settings_about_server_version_unknown,Unknown,Okänd,module=settings settings_about_licences,Open-source licences,Licenser för öppen källkod,module=settings +settings_category_archived_snackbar,%1$s was archived,%1$s arkiverades,module=settings; S-15 snackbar after Arkivera; %1$s category name; M7f +settings_category_unarchived_snackbar,%1$s was restored,%1$s återställdes,module=settings; S-15 snackbar after Återställ från arkiv; %1$s category name; M7f +settings_recurring_summary_none,None,Inga,module=settings; S-13 recurring row subtitle without templates; M7f +settings_expanded,expanded,utfälld,module=settings; TalkBack state of the archived section (design-spec §3.11 CollapsibleRow); M7f +settings_collapsed,collapsed,hopfälld,module=settings; TalkBack state of the archived section; M7f +settings_partner_missing,Partner has not joined,Partner saknas,module=settings; S-13 partner row before the partner joins; M7f