M8b: UI fixes from the first phone test

- Invite code field (S-1): the value stays the raw normalised code and the
  grouping hyphens are a VisualTransformation with an exact OffsetMapping, so
  the cursor stays after the last typed character when a hyphen appears and a
  backspace after a hyphen removes the character before it. Unit tests.
- Category quick row and grid: the selected item's check badge is drawn in a
  layer above the clipped item, so it is no longer cut at the tile corner.
- Edge to edge: KvittTopAppBar pads for the top safe-drawing inset (status
  bar) with its fill behind it; an ancestor's inset is consumed, not doubled.
  Catalogue sample with a 24 dp inset.
- Stacked bar chart corners 4 / 2 dp (owner override of 10 / 4, proposed D-097).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 21:08:08 +02:00
parent e2dba84154
commit 328c4666c4
7 changed files with 183 additions and 53 deletions
@@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
@@ -550,8 +551,13 @@ fun TopBarSheet() {
KvittTopAppBar(w("Ny utgift", "New expense"), navigation = TopBarNavigation.Close, actions = {
KvittButton(w("Spara", "Save"), {}, icon = KvittIcon.Check)
})
// Edge to edge (M8b): a 24 dp status-bar inset as on a phone; the fill runs behind it, the content sits below it.
KvittTopAppBar(w("Statistik", "Analytics"), scrolled = true, windowInsets = WindowInsets(top = StatusBarSample))
}
/** A typical status-bar height, for the edge-to-edge top app bar sample. */
private val StatusBarSample = 24.dp
@Composable
fun NavigationSheet() {
KvittNavigationBar(KvittDestination.Home, {})
@@ -66,7 +66,7 @@ import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.SpendBucket
// The analytics charts of docs/ux/design-spec.md §3.10, drawn with Compose Canvas (chart decision D-063: the chart
// library evaluated draws stacked columns, but not the per-bar TalkBack nodes, 2 dp segment gaps and 10/4 corners the spec binds).
// library evaluated draws stacked columns, but not the per-bar TalkBack nodes, 2 dp segment gaps and 4/2 corners the spec binds).
/** A chart card (design-spec §3.10): padding 18 / 16 / 16, title title-medium. */
@Composable
@@ -97,8 +97,8 @@ fun niceStep(maxKronor: Long): Long {
/**
* Spending over time (design-spec §3.10): 170 dp plot, a 44 dp y gutter (wider when its labels need it, at 200 %) with gridlines at round whole-kronor values,
* one bar per bucket (empty buckets as zero), segments stacked in [groups] order with 2 dp gaps, top corners 10 and
* bottom 4; x labels under each bar (month or week number, [xCaption] "Vecka" under the axis). Bars narrower than
* one bar per bucket (empty buckets as zero), segments stacked in [groups] order with 2 dp gaps, top corners 4 and
* bottom 2; x labels under each bar (month or week number, [xCaption] "Vecka" under the axis). Bars narrower than
* 16 dp scroll horizontally, starting at the newest. Each bar is a focusable button read as [barDescription];
* the plot's summary sentence is [summary].
*/
@@ -248,43 +248,49 @@ fun CategoryQuickRow(categories: List<Category>, selected: CategoryId?, onPick:
}
}
/** One category item of the quick row or the grid sheet. */
/**
* One category item of the quick row or the grid sheet. The check badge of a selected item overhangs the tile's top-right
* corner; it is drawn in a layer above the clipped, clickable item so the item's ripple clip never cuts it (M8b).
*/
@Composable
fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val name = categoryName(category)
Column(
modifier
.clip(KvittTheme.shapes.menu)
.selectable(selected, role = Role.RadioButton, onClick = onClick)
.semantics(mergeDescendants = true) {}
.padding(vertical = KvittTheme.spacing.s1),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
) {
Box {
val badge = KvittDimens.checkBadge + KvittDimens.checkBadgeRing * 2
Box(modifier) {
Column(
Modifier
.fillMaxWidth()
.clip(KvittTheme.shapes.menu)
.selectable(selected, role = Role.RadioButton, onClick = onClick)
.semantics(mergeDescendants = true) {}
.padding(vertical = KvittTheme.spacing.s1),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
) {
CategoryTile(category, KvittDimens.tileEditor, style = if (selected) TileStyle.Solid else TileStyle.Container)
if (selected) {
Box(
Modifier
.align(Alignment.TopEnd)
.offset(x = KvittDimens.checkBadgeOffsetX, y = -KvittDimens.checkBadgeOffsetY)
.size(KvittDimens.checkBadge + KvittDimens.checkBadgeRing * 2)
.background(scheme.surface, CircleShape)
.padding(KvittDimens.checkBadgeRing)
.background(scheme.onSurface, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
painterResource(CheckBadgeIcon),
null,
Modifier.size(KvittDimens.iconBadge),
tint = scheme.surface,
)
}
TileLabel(name, selected)
}
if (selected) {
// Same place as before: right edge checkBadgeOffsetX past the tile's, top checkBadgeOffsetY above the tile's.
Box(
Modifier
.align(Alignment.TopCenter)
.offset(x = KvittDimens.tileEditor / 2 + KvittDimens.checkBadgeOffsetX - badge / 2, y = KvittTheme.spacing.s1 - KvittDimens.checkBadgeOffsetY)
.size(badge)
.background(scheme.surface, CircleShape)
.padding(KvittDimens.checkBadgeRing)
.background(scheme.onSurface, CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
painterResource(CheckBadgeIcon),
null,
Modifier.size(KvittDimens.iconBadge),
tint = scheme.surface,
)
}
}
TileLabel(name, selected)
}
}
@@ -11,6 +11,11 @@ import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
@@ -70,7 +75,9 @@ enum class TopBarNavigation { Back, Close, None }
/**
* The top app bar (design-spec §3.11): 64 dp, `surface` (`surface-container` when [scrolled]), a 48 dp navigation
* button ("Tillbaka" / "Avbryt"), title title-large on one line, optional subtitle, actions. [title] null shows the
* wordmark (top destinations).
* wordmark (top destinations). The app draws edge to edge, so the bar handles the status-bar inset ([windowInsets],
* the top of `safeDrawing`): its fill runs behind the status bar and its content sits below it. Insets an ancestor has
* already applied (`statusBarsPadding`) are consumed and not added twice.
*/
@Composable
fun KvittTopAppBar(
@@ -80,6 +87,7 @@ fun KvittTopAppBar(
onNavigation: () -> Unit = {},
subtitle: String? = null,
scrolled: Boolean = false,
windowInsets: WindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top),
actions: @Composable RowScope.() -> Unit = {},
) {
val scheme = MaterialTheme.colorScheme
@@ -87,6 +95,7 @@ fun KvittTopAppBar(
modifier
.fillMaxWidth()
.background(if (scrolled) scheme.surfaceContainer else scheme.surface)
.windowInsetsPadding(windowInsets)
.heightIn(min = KvittTheme.spacing.topAppBarHeight)
.padding(horizontal = KvittTheme.spacing.s1),
verticalAlignment = Alignment.CenterVertically,
@@ -228,7 +237,9 @@ suspend fun SnackbarHostState.showKvittSnackbar(
/**
* 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. A snackbar shown with [showKvittSnackbar] is resolved
* navigation; the status-bar inset is handled by the app bar ([KvittTopAppBar]) and the navigation-bar inset by the
* bottom bar ([KvittNavigationBar]); without either, the content padding carries the system-bar insets (Material 3's
* default `contentWindowInsets`), so content never draws under the system bars. 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
@@ -319,7 +330,7 @@ fun InviteCodeField(
) {
val focus = LocalFocusManager.current
KvittTextField(
value = groupInviteCode(normalizeInviteCode(value)),
value = normalizeInviteCode(value),
onValueChange = { onValueChange(normalizeInviteCode(it)) },
label = stringResource(R.string.common_invite_code),
modifier = modifier,
@@ -334,7 +345,7 @@ fun InviteCodeField(
textStyle = MaterialTheme.typography.titleLarge,
enabled = enabled,
readOnly = !enabled,
visualTransformation = GroupBreaks,
visualTransformation = InviteCodeGrouping,
trailing = if (enabled) {
{ KvittButton(pasteLabel, onPaste, kind = ButtonKind.Text, icon = KvittIcon.ContentPaste) }
} else {
@@ -347,21 +358,33 @@ fun InviteCodeField(
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.
* Groups the raw invite code for display (S-1): "7K3MQX9D2HPA" shows as "7K3M-QX9D-2HPA". The field's value stays the raw
* normalised code, so the platform keeps the cursor where the user typed and never lands it before a hyphen that
* appeared after the fourth or eighth character (M8b). A hyphen is shown only between groups, followed by a zero-width
* space 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. A cursor at a group boundary sits after the separator.
*/
private object GroupBreaks : VisualTransformation {
private const val BREAK = '\u200B'
internal object InviteCodeGrouping : VisualTransformation {
const val SEPARATOR = "-\u200B"
private const val GROUP = 4
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 == '-' }
val raw = text.text
val shown = raw.chunked(GROUP).joinToString(SEPARATOR)
return TransformedText(AnnotatedString(shown), Mapping(raw.length, shown))
}
override fun transformedToOriginal(offset: Int): Int = offset - shown.take(offset).count { it == BREAK }
private class Mapping(private val length: Int, private val shown: String) : OffsetMapping {
override fun originalToTransformed(offset: Int): Int {
val at = offset.coerceIn(0, length)
// Separators shown before the cursor: one per group boundary at or before it that is not the end of the text.
val separators = (GROUP until length step GROUP).count { it <= at }
return at + separators * SEPARATOR.length
}
override fun transformedToOriginal(offset: Int): Int {
val at = offset.coerceIn(0, shown.length)
return (0 until at).count { shown[it] != '-' && shown[it] != '\u200B' }
}
return TransformedText(AnnotatedString(shown), mapping)
}
}
@@ -61,9 +61,9 @@ data class KvittShapes(
val fab: CornerBasedShape = RoundedCornerShape(20.dp),
/** Keypad sheet: top corners 28. */
val sheetTop: CornerBasedShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
/** Chart bars: top 10, bottom 4. */
val chartBarTopRadius: Float = 10f,
val chartBarBottomRadius: Float = 4f,
/** Chart bars: top 4, bottom 2 (the owner's override of design-spec §3.10's 10 / 4, proposed D-097). */
val chartBarTopRadius: Float = 4f,
val chartBarBottomRadius: Float = 2f,
/** Legend swatch: radius 3. */
val legendSwatch: CornerBasedShape = RoundedCornerShape(3.dp),
/** Invite code chips: radius 14. */
@@ -0,0 +1,96 @@
package se.hagstedt.kvitt.design.component
import androidx.compose.ui.text.AnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The invite code field's grouping (S-1, M8b): the value is the raw normalised code and the hyphens are display only, so
* typing one character at a time keeps the cursor after the last character, and deleting across a hyphen removes the
* character before it. Each step mirrors the field: the edit lands at the original cursor, the field normalises the
* text, and the cursor is shown through the offset mapping.
*/
class InviteCodeFieldTest {
private val zw = '​'
/** The field's raw value and cursor (in the raw text). */
private data class Field(val raw: String, val cursor: Int)
private fun shown(raw: String) = InviteCodeGrouping.filter(AnnotatedString(raw))
private fun type(field: Field, typed: String): Field {
val edited = field.raw.substring(0, field.cursor) + typed + field.raw.substring(field.cursor)
val raw = normalizeInviteCode(edited)
return Field(raw, (field.cursor + typed.length).coerceAtMost(raw.length))
}
private fun backspace(field: Field): Field =
if (field.cursor == 0) field else Field(field.raw.removeRange(field.cursor - 1, field.cursor), field.cursor - 1)
/** The cursor the user sees, in the shown text. */
private fun shownCursor(field: Field) = shown(field.raw).offsetMapping.originalToTransformed(field.cursor)
@Test
fun typingTwelveCharactersOneByOneKeepsTheCursorAtTheEnd() {
val code = "7k3mqx9d2hpa"
var field = Field("", 0)
code.forEachIndexed { index, c ->
field = type(field, c.toString())
val expected = code.uppercase().take(index + 1)
assertEquals(expected, field.raw)
val text = shown(field.raw).text.text
assertEquals(expected.chunked(4).joinToString("-$zw"), text)
assertEquals("cursor after character ${index + 1}", text.length, shownCursor(field))
}
assertEquals("7K3M-${zw}QX9D-${zw}2HPA", shown(field.raw).text.text)
// A 13th character is dropped and the cursor stays at the end.
field = type(field, "X")
assertEquals("7K3MQX9D2HPA", field.raw)
assertEquals(shown(field.raw).text.text.length, shownCursor(field))
}
@Test
fun theFifthCharacterIsShownAfterTheHyphenWithTheCursorAfterIt() {
val field = type(Field("ABCD", 4), "E")
assertEquals("ABCD-${zw}E", shown(field.raw).text.text)
assertEquals(7, shownCursor(field))
assertEquals("ABCDEF", type(field, "F").raw)
}
@Test
fun deletingAcrossAHyphenRemovesTheCharacterBeforeIt() {
val mapping = shown("ABCDEFGH").offsetMapping
// The cursor tapped right after the hyphen (and between the hyphen and its zero-width space) is raw offset 4.
assertEquals(4, mapping.transformedToOriginal(6))
assertEquals(4, mapping.transformedToOriginal(5))
val field = backspace(Field("ABCDEFGH", mapping.transformedToOriginal(6)))
assertEquals("ABCEFGH", field.raw)
assertEquals("ABCE-${zw}FGH", shown(field.raw).text.text)
assertEquals(3, shownCursor(field))
}
@Test
fun backspacingFromTheEndRemovesOneCharacterAtATime() {
var field = Field("7K3MQX9D2HPA", 12)
for (left in 11 downTo 0) {
field = backspace(field)
assertEquals(left, field.raw.length)
assertEquals(shown(field.raw).text.text.length, shownCursor(field))
}
}
@Test
fun offsetsMapBothWaysWithinBounds() {
listOf("", "A", "ABCD", "ABCDE", "ABCDEFGH", "ABCDEFGHJ", "ABCDEFGHJKMN").forEach { raw ->
val transformed = shown(raw)
val mapping = transformed.offsetMapping
for (o in 0..raw.length) {
val t = mapping.originalToTransformed(o)
assertTrue("$raw: $o -> $t", t in 0..transformed.text.length)
assertEquals("$raw round trip at $o", o, mapping.transformedToOriginal(t))
}
for (t in 0..transformed.text.length) assertTrue("$raw: back from $t", mapping.transformedToOriginal(t) in 0..raw.length)
}
}
}
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
@@ -97,7 +96,7 @@ fun WaitingContent(
KvittScaffold(
modifier = modifier,
snackbarHostState = snackbarHostState,
topBar = { KvittTopAppBar(title = null, modifier = Modifier.statusBarsPadding()) },
topBar = { KvittTopAppBar(title = null) },
bottomBar = { KvittNavigationBar(KvittDestination.Home, actions.onNavigate) },
) { padding ->
val me = state.me ?: return@KvittScaffold