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.ExperimentalLayoutApi
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -550,8 +551,13 @@ fun TopBarSheet() {
|
|||||||
KvittTopAppBar(w("Ny utgift", "New expense"), navigation = TopBarNavigation.Close, actions = {
|
KvittTopAppBar(w("Ny utgift", "New expense"), navigation = TopBarNavigation.Close, actions = {
|
||||||
KvittButton(w("Spara", "Save"), {}, icon = KvittIcon.Check)
|
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
|
@Composable
|
||||||
fun NavigationSheet() {
|
fun NavigationSheet() {
|
||||||
KvittNavigationBar(KvittDestination.Home, {})
|
KvittNavigationBar(KvittDestination.Home, {})
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ import se.hagstedt.kvitt.design.theme.KvittDimens
|
|||||||
import se.hagstedt.kvitt.model.SpendBucket
|
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
|
// 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. */
|
/** A chart card (design-spec §3.10): padding 18 / 16 / 16, title title-medium. */
|
||||||
@Composable
|
@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,
|
* 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
|
* one bar per bucket (empty buckets as zero), segments stacked in [groups] order with 2 dp gaps, top corners 4 and
|
||||||
* bottom 4; x labels under each bar (month or week number, [xCaption] "Vecka" under the axis). Bars narrower than
|
* 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];
|
* 16 dp scroll horizontally, starting at the newest. Each bar is a focusable button read as [barDescription];
|
||||||
* the plot's summary sentence is [summary].
|
* the plot's summary sentence is [summary].
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -248,13 +248,19 @@ 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
|
@Composable
|
||||||
fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
val name = categoryName(category)
|
val name = categoryName(category)
|
||||||
|
val badge = KvittDimens.checkBadge + KvittDimens.checkBadgeRing * 2
|
||||||
|
Box(modifier) {
|
||||||
Column(
|
Column(
|
||||||
modifier
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
.clip(KvittTheme.shapes.menu)
|
.clip(KvittTheme.shapes.menu)
|
||||||
.selectable(selected, role = Role.RadioButton, onClick = onClick)
|
.selectable(selected, role = Role.RadioButton, onClick = onClick)
|
||||||
.semantics(mergeDescendants = true) {}
|
.semantics(mergeDescendants = true) {}
|
||||||
@@ -262,14 +268,16 @@ fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, mod
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
|
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
|
||||||
) {
|
) {
|
||||||
Box {
|
|
||||||
CategoryTile(category, KvittDimens.tileEditor, style = if (selected) TileStyle.Solid else TileStyle.Container)
|
CategoryTile(category, KvittDimens.tileEditor, style = if (selected) TileStyle.Solid else TileStyle.Container)
|
||||||
|
TileLabel(name, selected)
|
||||||
|
}
|
||||||
if (selected) {
|
if (selected) {
|
||||||
|
// Same place as before: right edge checkBadgeOffsetX past the tile's, top checkBadgeOffsetY above the tile's.
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.TopEnd)
|
.align(Alignment.TopCenter)
|
||||||
.offset(x = KvittDimens.checkBadgeOffsetX, y = -KvittDimens.checkBadgeOffsetY)
|
.offset(x = KvittDimens.tileEditor / 2 + KvittDimens.checkBadgeOffsetX - badge / 2, y = KvittTheme.spacing.s1 - KvittDimens.checkBadgeOffsetY)
|
||||||
.size(KvittDimens.checkBadge + KvittDimens.checkBadgeRing * 2)
|
.size(badge)
|
||||||
.background(scheme.surface, CircleShape)
|
.background(scheme.surface, CircleShape)
|
||||||
.padding(KvittDimens.checkBadgeRing)
|
.padding(KvittDimens.checkBadgeRing)
|
||||||
.background(scheme.onSurface, CircleShape),
|
.background(scheme.onSurface, CircleShape),
|
||||||
@@ -284,8 +292,6 @@ fun CategoryItem(category: Category, selected: Boolean, onClick: () -> Unit, mod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TileLabel(name, selected)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+39
-16
@@ -11,6 +11,11 @@ import androidx.compose.foundation.layout.FlowRow
|
|||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
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
|
* 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
|
* 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
|
@Composable
|
||||||
fun KvittTopAppBar(
|
fun KvittTopAppBar(
|
||||||
@@ -80,6 +87,7 @@ fun KvittTopAppBar(
|
|||||||
onNavigation: () -> Unit = {},
|
onNavigation: () -> Unit = {},
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
scrolled: Boolean = false,
|
scrolled: Boolean = false,
|
||||||
|
windowInsets: WindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top),
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val scheme = MaterialTheme.colorScheme
|
val scheme = MaterialTheme.colorScheme
|
||||||
@@ -87,6 +95,7 @@ fun KvittTopAppBar(
|
|||||||
modifier
|
modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(if (scrolled) scheme.surfaceContainer else scheme.surface)
|
.background(if (scrolled) scheme.surfaceContainer else scheme.surface)
|
||||||
|
.windowInsetsPadding(windowInsets)
|
||||||
.heightIn(min = KvittTheme.spacing.topAppBarHeight)
|
.heightIn(min = KvittTheme.spacing.topAppBarHeight)
|
||||||
.padding(horizontal = KvittTheme.spacing.s1),
|
.padding(horizontal = KvittTheme.spacing.s1),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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
|
* 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.
|
* from its resources while it shows, so it follows the current language; one shown with a plain `String` keeps it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
@@ -319,7 +330,7 @@ fun InviteCodeField(
|
|||||||
) {
|
) {
|
||||||
val focus = LocalFocusManager.current
|
val focus = LocalFocusManager.current
|
||||||
KvittTextField(
|
KvittTextField(
|
||||||
value = groupInviteCode(normalizeInviteCode(value)),
|
value = normalizeInviteCode(value),
|
||||||
onValueChange = { onValueChange(normalizeInviteCode(it)) },
|
onValueChange = { onValueChange(normalizeInviteCode(it)) },
|
||||||
label = stringResource(R.string.common_invite_code),
|
label = stringResource(R.string.common_invite_code),
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
@@ -334,7 +345,7 @@ fun InviteCodeField(
|
|||||||
textStyle = MaterialTheme.typography.titleLarge,
|
textStyle = MaterialTheme.typography.titleLarge,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
readOnly = !enabled,
|
readOnly = !enabled,
|
||||||
visualTransformation = GroupBreaks,
|
visualTransformation = InviteCodeGrouping,
|
||||||
trailing = if (enabled) {
|
trailing = if (enabled) {
|
||||||
{ KvittButton(pasteLabel, onPaste, kind = ButtonKind.Text, icon = KvittIcon.ContentPaste) }
|
{ KvittButton(pasteLabel, onPaste, kind = ButtonKind.Text, icon = KvittIcon.ContentPaste) }
|
||||||
} else {
|
} else {
|
||||||
@@ -347,21 +358,33 @@ fun InviteCodeField(
|
|||||||
private const val INVITE_MAX_LINES = 3
|
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
|
* Groups the raw invite code for display (S-1): "7K3MQX9D2HPA" shows as "7K3M-QX9D-2HPA". The field's value stays the raw
|
||||||
* breaker does not break between a hyphen and a digit ("QX9D-2HPA"), which cut the value at 200 % font scale. The value
|
* normalised code, so the platform keeps the cursor where the user typed and never lands it before a hyphen that
|
||||||
* itself is unchanged.
|
* 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 {
|
internal object InviteCodeGrouping : VisualTransformation {
|
||||||
private const val BREAK = '\u200B'
|
const val SEPARATOR = "-\u200B"
|
||||||
|
private const val GROUP = 4
|
||||||
|
|
||||||
override fun filter(text: AnnotatedString): TransformedText {
|
override fun filter(text: AnnotatedString): TransformedText {
|
||||||
val original = text.text
|
val raw = text.text
|
||||||
val shown = buildString { original.forEach { c -> append(c); if (c == '-') append(BREAK) } }
|
val shown = raw.chunked(GROUP).joinToString(SEPARATOR)
|
||||||
val mapping = object : OffsetMapping {
|
return TransformedText(AnnotatedString(shown), Mapping(raw.length, shown))
|
||||||
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 }
|
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),
|
val fab: CornerBasedShape = RoundedCornerShape(20.dp),
|
||||||
/** Keypad sheet: top corners 28. */
|
/** Keypad sheet: top corners 28. */
|
||||||
val sheetTop: CornerBasedShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
val sheetTop: CornerBasedShape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
|
||||||
/** Chart bars: top 10, bottom 4. */
|
/** Chart bars: top 4, bottom 2 (the owner's override of design-spec §3.10's 10 / 4, proposed D-097). */
|
||||||
val chartBarTopRadius: Float = 10f,
|
val chartBarTopRadius: Float = 4f,
|
||||||
val chartBarBottomRadius: Float = 4f,
|
val chartBarBottomRadius: Float = 2f,
|
||||||
/** Legend swatch: radius 3. */
|
/** Legend swatch: radius 3. */
|
||||||
val legendSwatch: CornerBasedShape = RoundedCornerShape(3.dp),
|
val legendSwatch: CornerBasedShape = RoundedCornerShape(3.dp),
|
||||||
/** Invite code chips: radius 14. */
|
/** 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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -97,7 +96,7 @@ fun WaitingContent(
|
|||||||
KvittScaffold(
|
KvittScaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
snackbarHostState = snackbarHostState,
|
snackbarHostState = snackbarHostState,
|
||||||
topBar = { KvittTopAppBar(title = null, modifier = Modifier.statusBarsPadding()) },
|
topBar = { KvittTopAppBar(title = null) },
|
||||||
bottomBar = { KvittNavigationBar(KvittDestination.Home, actions.onNavigate) },
|
bottomBar = { KvittNavigationBar(KvittDestination.Home, actions.onNavigate) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
val me = state.me ?: return@KvittScaffold
|
val me = state.me ?: return@KvittScaffold
|
||||||
|
|||||||
Reference in New Issue
Block a user