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:
2026-10-18 11:35:06 +02:00
parent c4ab35f66e
commit b24c8b5ba3
49 changed files with 655 additions and 261 deletions
@@ -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
}
@@ -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)
}
@@ -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)
}
}
@@ -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)) {
@@ -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),