diff --git a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt index 0516e5a..29b1b65 100644 --- a/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt +++ b/android/core/design/src/debug/kotlin/se/hagstedt/kvitt/design/catalogue/Catalogue.kt @@ -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, {}) diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt index 3c161cd..0c4ea35 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/chart/Charts.kt @@ -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]. */ diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt index fdd7224..7d942a4 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Editor.kt @@ -248,43 +248,49 @@ fun CategoryQuickRow(categories: List, 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) } } diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt index ceb8e4c..0d5601a 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/component/Scaffold.kt @@ -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) } } diff --git a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittShapes.kt b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittShapes.kt index 04d8666..6e11f15 100644 --- a/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittShapes.kt +++ b/android/core/design/src/main/kotlin/se/hagstedt/kvitt/design/theme/KvittShapes.kt @@ -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. */ diff --git a/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/InviteCodeFieldTest.kt b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/InviteCodeFieldTest.kt new file mode 100644 index 0000000..4bd6871 --- /dev/null +++ b/android/core/design/src/test/kotlin/se/hagstedt/kvitt/design/component/InviteCodeFieldTest.kt @@ -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) + } + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt index e54bd7b..8357c41 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt @@ -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