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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+38
-15
@@ -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. */
|
||||
|
||||
+96
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user