M6b: core:design fixes after the M7 wave
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+37
-3
@@ -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<Pair<String, @Composable ColumnScope.() -> Unit>> = li
|
||||
val LargeFontSheets: Set<String> = 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -307,6 +307,8 @@ data class FilterToggle(val groupLabel: String, val options: List<String>, 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))
|
||||
|
||||
@@ -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<Category> = 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<Category>, 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)
|
||||
|
||||
@@ -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<Category>, frequent: List<CategoryId>, selected: CategoryId?): List<Category> {
|
||||
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<Category>, frequent: List<CategoryId>, selected: CategoryId?): List<Category> {
|
||||
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
|
||||
|
||||
@@ -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 `<b>` and `<i>` 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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Any>,
|
||||
@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<Any> = 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UserId, Int>, 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<UserId, Int>, viewer: UserId): Int = (defaultShares[viewer] ?: HALF_BP) / BP_PER_PERCENT
|
||||
|
||||
private const val HALF_BP = 5_000
|
||||
private const val BP_PER_PERCENT = 100
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+19
-1
@@ -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<Int, Int> {
|
||||
val year = period.substringBefore('-').toInt()
|
||||
val month = period.substringAfter('-').take(2).toInt()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- required on a name field -->
|
||||
<string name="common_name_required">Skriv ett namn</string>
|
||||
<string name="common_retry">Försök igen</string>
|
||||
<string name="common_ok">OK</string>
|
||||
<string name="common_undo">Ångra</string>
|
||||
<!-- TalkBack label of the overflow icon -->
|
||||
<string name="common_more_options">Fler alternativ</string>
|
||||
<!-- the viewer as a name, sentence start -->
|
||||
@@ -160,6 +162,7 @@
|
||||
<string name="editor_split_mode_exact">Exakta belopp</string>
|
||||
<string name="editor_split_mode_full">Allt på en person</string>
|
||||
<string name="editor_split_full_on">Allt på %1$s</string>
|
||||
<string name="editor_split_full_on_you">Allt på dig</string>
|
||||
<!-- full mode prompt -->
|
||||
<string name="editor_split_full_question">Vem står för hela beloppet?</string>
|
||||
<!-- trailing text of the other person's read-only field in exact mode -->
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
<!-- required on a name field -->
|
||||
<string name="common_name_required">Enter a name</string>
|
||||
<string name="common_retry">Try again</string>
|
||||
<string name="common_ok">OK</string>
|
||||
<string name="common_undo">Undo</string>
|
||||
<!-- TalkBack label of the overflow icon -->
|
||||
<string name="common_more_options">More options</string>
|
||||
<!-- the viewer as a name, sentence start -->
|
||||
@@ -162,6 +164,7 @@
|
||||
<string name="editor_split_mode_exact">Exact amounts</string>
|
||||
<string name="editor_split_mode_full">All on one person</string>
|
||||
<string name="editor_split_full_on">All on %1$s</string>
|
||||
<string name="editor_split_full_on_you">All on you</string>
|
||||
<!-- full mode prompt -->
|
||||
<string name="editor_split_full_question">Who pays the whole amount?</string>
|
||||
<!-- trailing text of the other person's read-only field in exact mode -->
|
||||
|
||||
+22
-2
@@ -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
|
||||
|
||||
+80
@@ -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 `<b>` 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<String>()
|
||||
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() })
|
||||
}
|
||||
}
|
||||
+13
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+11
@@ -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)))
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+5
-2
@@ -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) {
|
||||
|
||||
+10
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,12 +42,8 @@
|
||||
<item quantity="one">%d utgift</item>
|
||||
<item quantity="other">%d utgifter</item>
|
||||
</plurals>
|
||||
<!-- proposed (M7e): radio-group name of the month/week toggle -->
|
||||
<string name="analytics_granularity">Indelning</string>
|
||||
<!-- proposed (M7e): radio-group name of the measure toggle -->
|
||||
<string name="analytics_measure">Mått</string>
|
||||
<!-- proposed (M7e): radio-group name of the group-by toggle -->
|
||||
<string name="analytics_group_by">Gruppera efter</string>
|
||||
<!-- proposed (M7e): confirm button of the custom range picker -->
|
||||
<string name="analytics_range_apply">Använd</string>
|
||||
</resources>
|
||||
|
||||
@@ -42,12 +42,8 @@
|
||||
<item quantity="one">%d expense</item>
|
||||
<item quantity="other">%d expenses</item>
|
||||
</plurals>
|
||||
<!-- proposed (M7e): radio-group name of the month/week toggle -->
|
||||
<string name="analytics_granularity">Interval</string>
|
||||
<!-- proposed (M7e): radio-group name of the measure toggle -->
|
||||
<string name="analytics_measure">Measure</string>
|
||||
<!-- proposed (M7e): radio-group name of the group-by toggle -->
|
||||
<string name="analytics_group_by">Group by</string>
|
||||
<!-- proposed (M7e): confirm button of the custom range picker -->
|
||||
<string name="analytics_range_apply">Apply</string>
|
||||
</resources>
|
||||
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-7
@@ -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)
|
||||
}
|
||||
|
||||
+10
-16
@@ -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). */
|
||||
|
||||
@@ -19,7 +19,5 @@
|
||||
<string name="expense_detail_deleted_body">Den räknas inte längre i saldon eller statistik.</string>
|
||||
<string name="expense_detail_missing">Utgiften finns inte på telefonen än</string>
|
||||
<string name="expense_detail_missing_body">Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.</string>
|
||||
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
|
||||
<string name="expense_detail_sync_now">Synka nu</string>
|
||||
<string name="expense_split_full_on_you">Allt på dig</string>
|
||||
</resources>
|
||||
|
||||
@@ -19,7 +19,5 @@
|
||||
<string name="expense_detail_deleted_body">It no longer counts in balances or analytics.</string>
|
||||
<string name="expense_detail_missing">This expense is not on the phone yet</string>
|
||||
<string name="expense_detail_missing_body">It may have just been added on another phone. Sync to fetch it.</string>
|
||||
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
|
||||
<string name="expense_detail_sync_now">Sync now</string>
|
||||
<string name="expense_split_full_on_you">All on you</string>
|
||||
</resources>
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
<!-- opens the system date settings -->
|
||||
<string name="home_sync_status_open_date_settings">Datum och tid</string>
|
||||
<string name="home_sync_status_last_attempt_clock">Senaste försöket nekades: klockan</string>
|
||||
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||
<string name="home_archived_expanded">utfälld</string>
|
||||
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||
<string name="home_archived_collapsed">hopfälld</string>
|
||||
</resources>
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
<!-- opens the system date settings -->
|
||||
<string name="home_sync_status_open_date_settings">Date & time</string>
|
||||
<string name="home_sync_status_last_attempt_clock">Last attempt refused: the clock</string>
|
||||
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||
<string name="home_archived_expanded">expanded</string>
|
||||
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
|
||||
<string name="home_archived_collapsed">collapsed</string>
|
||||
</resources>
|
||||
|
||||
-41
@@ -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
|
||||
}
|
||||
+5
-46
@@ -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)
|
||||
}
|
||||
|
||||
+8
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-10
@@ -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)) {
|
||||
|
||||
+5
-4
@@ -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),
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Keys marked PROPOSED are
|
||||
not in strings.csv yet; the M7f report proposes them. Generated; change the CSV first. -->
|
||||
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
|
||||
first. -->
|
||||
<resources>
|
||||
<string name="settings_profile_offline_banner">Kräver anslutning. Profilen kan bara ändras när telefonen är online.</string>
|
||||
<string name="settings_title">Inställningar</string>
|
||||
@@ -179,16 +179,10 @@
|
||||
<string name="settings_about_server_version">Serverversion</string>
|
||||
<string name="settings_about_server_version_unknown">Okänd</string>
|
||||
<string name="settings_about_licences">Licenser för öppen källkod</string>
|
||||
<!-- PROPOSED (M7f report): S-15 snackbar after Arkivera; %1$s category name -->
|
||||
<string name="settings_category_archived_snackbar">%1$s arkiverades</string>
|
||||
<!-- PROPOSED (M7f report): S-15 snackbar after Återställ från arkiv -->
|
||||
<string name="settings_category_unarchived_snackbar">%1$s återställdes</string>
|
||||
<!-- PROPOSED (M7f report): S-13 recurring row subtitle without templates (design-spec S-13) -->
|
||||
<string name="settings_recurring_summary_none">Inga</string>
|
||||
<!-- PROPOSED (M7f report): TalkBack state of the archived section (design-spec §3.11 CollapsibleRow) -->
|
||||
<string name="settings_expanded">utfälld</string>
|
||||
<!-- PROPOSED (M7f report): TalkBack state of the archived section -->
|
||||
<string name="settings_collapsed">hopfälld</string>
|
||||
<!-- PROPOSED (M7f report): S-13 partner row before the partner joins -->
|
||||
<string name="settings_partner_missing">Partner saknas</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Keys marked PROPOSED are
|
||||
not in strings.csv yet; the M7f report proposes them. Generated; change the CSV first. -->
|
||||
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
|
||||
first. -->
|
||||
<resources>
|
||||
<string name="settings_profile_offline_banner">Needs connection. Your profile can only be changed online.</string>
|
||||
<string name="settings_title">Settings</string>
|
||||
@@ -179,16 +179,10 @@
|
||||
<string name="settings_about_server_version">Server version</string>
|
||||
<string name="settings_about_server_version_unknown">Unknown</string>
|
||||
<string name="settings_about_licences">Open-source licences</string>
|
||||
<!-- PROPOSED (M7f report): S-15 snackbar after Arkivera; %1$s category name -->
|
||||
<string name="settings_category_archived_snackbar">%1$s was archived</string>
|
||||
<!-- PROPOSED (M7f report): S-15 snackbar after Återställ från arkiv -->
|
||||
<string name="settings_category_unarchived_snackbar">%1$s was restored</string>
|
||||
<!-- PROPOSED (M7f report): S-13 recurring row subtitle without templates (design-spec S-13) -->
|
||||
<string name="settings_recurring_summary_none">None</string>
|
||||
<!-- PROPOSED (M7f report): TalkBack state of the archived section (design-spec §3.11 CollapsibleRow) -->
|
||||
<string name="settings_expanded">expanded</string>
|
||||
<!-- PROPOSED (M7f report): TalkBack state of the archived section -->
|
||||
<string name="settings_collapsed">collapsed</string>
|
||||
<!-- PROPOSED (M7f report): S-13 partner row before the partner joins -->
|
||||
<string name="settings_partner_missing">Partner has not joined</string>
|
||||
</resources>
|
||||
|
||||
+1
-1
@@ -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() },
|
||||
|
||||
+2
-2
@@ -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)
|
||||
},
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
|
||||
proposed in the M7d report and not yet in strings.csv. -->
|
||||
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle. -->
|
||||
<resources>
|
||||
<string name="settle_title">Gör upp</string>
|
||||
<string name="settle_title_edit">Ändra betalning</string>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
|
||||
proposed in the M7d report and not yet in strings.csv. -->
|
||||
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle. -->
|
||||
<resources>
|
||||
<string name="settle_title">Settle up</string>
|
||||
<string name="settle_title_edit">Edit payment</string>
|
||||
|
||||
Reference in New Issue
Block a user