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
@@ -131,7 +131,7 @@ fun AccountEditorContent(ui: AccountEditorUi, actions: AccountEditorActions, mod
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(DesignR.string.common_discard_title),
body = "",
body = null,
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions.onDiscard,
@@ -58,6 +58,7 @@ import se.hagstedt.kvitt.model.Granularity
import se.hagstedt.kvitt.model.GroupBy
import se.hagstedt.kvitt.model.GroupKey
import se.hagstedt.kvitt.model.Measure
import se.hagstedt.kvitt.model.RangePreset
import se.hagstedt.kvitt.core.design.R as DesignR
/**
@@ -141,13 +142,15 @@ private fun Filters(state: AnalyticsUiState, events: AnalyticsEvents) {
listOf(stringResource(R.string.analytics_group_by_category), stringResource(R.string.analytics_group_by_account)),
GroupBy.entries.indexOf(filter.groupBy),
) { events.onGroupBy(GroupBy.entries[it]) },
// A custom range label ("Anpassad · 24 aug.–17 okt.") gets a row of its own (design frame S-11-analytics-week).
periodOwnRow = filter.preset == RangePreset.CUSTOM,
)
}
/** The range caption: "maj–okt 2026 · Hushållet", or "24 aug.–17 okt. · 8 veckor" in week granularity. */
@Composable
private fun totalCaption(data: AnalyticsState): String {
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage())
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage(), long = true)
val second = when {
data.filter.granularity == Granularity.WEEK -> pluralStringResource(R.plurals.analytics_weeks, data.buckets.size, data.buckets.size)
data.filter.measure == Measure.HOUSEHOLD -> stringResource(R.string.analytics_measure_household)
@@ -192,7 +195,7 @@ private fun Ready(content: AnalyticsContent.Ready, events: AnalyticsEvents, open
private fun OverTime(content: AnalyticsContent.Ready, onBucket: (String) -> Unit) {
val data = content.data
val groups = chartGroups(data, content.categories, content.accounts)
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage())
val range = rangeText(data.start, data.end, data.filter, LocalKvittClock.current.today, kvittLanguage(), long = true)
val highest = data.buckets.maxByOrNull { it.totalOre }
val lowest = data.buckets.minByOrNull { it.totalOre }
val summary = if (highest != null && lowest != null) {
@@ -31,14 +31,18 @@ internal fun presetLabel(preset: RangePreset): Int = when (preset) {
internal fun measureLabel(measure: Measure): Int =
if (measure == Measure.HOUSEHOLD) R.string.analytics_measure_household else R.string.analytics_measure_my_share
/** The range in a caption: whole months "maj–okt 2026" for a month preset, else days "24 aug.–17 okt.". */
internal fun rangeText(start: String, end: String, filter: AnalyticsFilter, today: LocalDate, language: KvittLanguage): String {
/**
* The range in a title or caption: whole months "maj–okt 2026" for a month preset, else days: short "24 aug.–17 okt."
* or, with [long] (the total card caption and the chart summary, design frame S-11-analytics-week), written out with the
* year "24 augusti–17 oktober 2026" (`DateFormatter.dayRangeLong`).
*/
internal fun rangeText(start: String, end: String, filter: AnalyticsFilter, today: LocalDate, language: KvittLanguage, long: Boolean = false): String {
val first = LocalDate.parse(start)
val last = LocalDate.parse(end)
return if (filter.granularity == Granularity.MONTH && filter.preset != RangePreset.CUSTOM) {
DateFormatter.monthRange(first, last, language)
} else {
DateFormatter.dayRange(first, last, today, language)
return when {
filter.granularity == Granularity.MONTH && filter.preset != RangePreset.CUSTOM -> DateFormatter.monthRange(first, last, language)
long -> DateFormatter.dayRangeLong(first, last, language)
else -> DateFormatter.dayRange(first, last, today, language)
}
}
@@ -42,12 +42,8 @@
<item quantity="one">%d utgift</item>
<item quantity="other">%d utgifter</item>
</plurals>
<!-- proposed (M7e): radio-group name of the month/week toggle -->
<string name="analytics_granularity">Indelning</string>
<!-- proposed (M7e): radio-group name of the measure toggle -->
<string name="analytics_measure">Mått</string>
<!-- proposed (M7e): radio-group name of the group-by toggle -->
<string name="analytics_group_by">Gruppera efter</string>
<!-- proposed (M7e): confirm button of the custom range picker -->
<string name="analytics_range_apply">Använd</string>
</resources>
@@ -42,12 +42,8 @@
<item quantity="one">%d expense</item>
<item quantity="other">%d expenses</item>
</plurals>
<!-- proposed (M7e): radio-group name of the month/week toggle -->
<string name="analytics_granularity">Interval</string>
<!-- proposed (M7e): radio-group name of the measure toggle -->
<string name="analytics_measure">Measure</string>
<!-- proposed (M7e): radio-group name of the group-by toggle -->
<string name="analytics_group_by">Group by</string>
<!-- proposed (M7e): confirm button of the custom range picker -->
<string name="analytics_range_apply">Apply</string>
</resources>
@@ -43,7 +43,6 @@ import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.MoneyText
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
@@ -141,12 +140,13 @@ private fun ColumnScope.Missing(state: ExpenseDetailUiState, actions: ExpenseDet
EmptyState(
title = stringResource(R.string.expense_detail_missing),
body = stringResource(R.string.expense_detail_missing_body),
icon = KvittIcon.SyncProblem,
action = stringResource(R.string.expense_detail_sync_now),
actionIcon = KvittIcon.Sync,
actionKind = ButtonKind.Tonal,
actionEnabled = state.online,
onAction = { if (state.online && !state.syncing) actions.syncNow() },
)
if (!state.online) NeedsConnectionHint(Modifier.align(Alignment.CenterHorizontally))
}
@Composable
@@ -256,7 +256,7 @@ private fun modeLabel(detail: ExpenseDetailState, me: User, partner: User): Stri
SplitMode.EXACT -> splitModeLabel(SplitMode.EXACT, "")
SplitMode.FULL -> {
val bearer = detail.shares.firstOrNull { it.shareOre > 0 }?.userId
if (bearer == me.id) stringResource(R.string.expense_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName)
if (bearer == me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, partner.displayName)
}
}
}
@@ -86,6 +86,7 @@ import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.money
import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.component.viewerPercentOf
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.design.icon.KvittIcon
@@ -354,9 +355,9 @@ internal fun EditorDialog(state: ExpenseEditorState, actions: ExpenseEditorActio
val confirm = stringResource(DesignR.string.common_discard)
val dismiss = stringResource(DesignR.string.common_keep_editing)
if (inline) {
ConfirmDialogContent(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
ConfirmDialogContent(KvittIcon.Delete, title, null, confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
} else {
ConfirmDialog(KvittIcon.Delete, title, "", confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
ConfirmDialog(KvittIcon.Delete, title, null, confirm, dismiss, actions::discard, actions::dismissOverlay, destructive = true)
}
}
EditorOverlay.Delete -> DeleteExpenseDialog(actions::confirmDelete, actions::dismissOverlay, inline)
@@ -399,7 +400,7 @@ private fun LocalizedDatePicker(date: String, today: String, actions: ExpenseEdi
)
val confirm: @Composable () -> Unit = {
TextButton({ picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it / DAY_MILLIS).toString()) } ?: actions.dismissOverlay() }) {
Text(stringResource(android.R.string.ok))
Text(stringResource(DesignR.string.common_ok))
}
}
val dismiss: @Composable () -> Unit = { TextButton(actions::dismissOverlay) { Text(stringResource(DesignR.string.common_cancel)) } }
@@ -426,8 +427,8 @@ private fun ErrorLine(text: String) {
/** The viewer's percent of the account's default ratio (bp in `Account.defaultShares`, §14.2.4). */
private fun accountViewerPercent(state: ExpenseEditorState): Int {
val me = state.me ?: return HALF
val bp = state.account?.defaultShares?.get(me.id) ?: return HALF
return (bp / PERCENT_HUNDREDTHS).toInt()
val account = state.account ?: return HALF
return viewerPercentOf(account.defaultShares, me.id)
}
@Composable
@@ -442,14 +443,14 @@ private fun modeChipLabel(state: ExpenseEditorState): String {
SplitMode.RATIO -> {
val language = kvittLanguage()
val viewer = form.ratioViewer.ore ?: return splitModeLabel(form.mode, "")
val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language))
val ratio = stringResource(DesignR.string.common_ratio, form.ratioViewer.display(language), AmountInput.percent(AmountInput.MAX_PERCENT_HUNDREDTHS - viewer).display(language))
splitModeLabel(form.mode, "") + " " + ratio
}
SplitMode.EXACT -> splitModeLabel(form.mode, "")
SplitMode.FULL -> {
val partner = state.partner
when {
form.fullOn == state.me?.id -> stringResource(R.string.expense_split_full_on_you)
form.fullOn == state.me?.id -> stringResource(DesignR.string.editor_split_full_on_you)
partner == null -> splitModeLabel(form.mode, "")
else -> stringResource(DesignR.string.editor_split_full_on, partner.displayName)
}
@@ -197,16 +197,16 @@ class ExpenseEditorViewModel @Inject constructor(
id = draft.id,
accountId = draft.accountId,
categoryId = draft.categoryId,
amount = draft.amountOre?.let(::moneyInput) ?: AmountInput(),
amount = draft.amountOre?.let(AmountInput::of) ?: AmountInput(),
description = draft.description,
notes = draft.notes,
date = draft.date,
payer = draft.payer,
mode = draft.mode,
ratioViewer = viewerBp?.let { AmountInput.of(it.toLong()) } ?: AmountInput(),
ratioPartner = viewerBp?.let { AmountInput.of(BP_TOTAL - it) } ?: AmountInput(),
ratioViewer = viewerBp?.let { AmountInput.percent(it.toLong()) } ?: AmountInput(),
ratioPartner = viewerBp?.let { AmountInput.percent(BP_TOTAL - it) } ?: AmountInput(),
exactTyped = me,
exactInput = exactMine?.let(::moneyInput) ?: AmountInput(),
exactInput = exactMine?.let(AmountInput::of) ?: AmountInput(),
fullOn = draft.fullOn ?: if (draft.payer == me) partner else me,
)
ui.update {
@@ -255,8 +255,9 @@ class ExpenseEditorViewModel @Inject constructor(
val choices = options.accounts.map { a -> AccountChoice(summaries[a.id] ?: AccountSummary(a, 0, 0), colorIndex[a.id] ?: 0) }
val account = form.accountId?.let { id -> options.accounts.firstOrNull { it.id == id } ?: env.all.firstOrNull { it.id == id } }
val active = options.categories.filter { !it.archived }.sortedBy { it.sortOrder }
// D-059/D-060: the account's used categories, filled up to five from sort_order; the selected one takes the fifth place.
val frequent = u.frequent.ifEmpty { active.take(QUICK_ROW).map { it.id } }
// D-059/D-060: the account's used categories as the repository gives them; quickRowCategories fills up to five
// from sort_order and puts the selected one fifth.
val frequent = u.frequent
return ExpenseEditorState(
ready = true,
isEdit = form.id != null,
@@ -306,7 +307,7 @@ class ExpenseEditorViewModel @Inject constructor(
KeypadTarget.RatioViewer, KeypadTarget.RatioPartner -> {
val typed = if (target == KeypadTarget.RatioViewer) form.ratioViewer else form.ratioPartner
val next = typed.press(key, AmountInput.MAX_PERCENT_HUNDREDTHS) ?: return false
val other = next.ore?.let { AmountInput.of(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput()
val other = next.ore?.let { AmountInput.percent(AmountInput.MAX_PERCENT_HUNDREDTHS - it) } ?: AmountInput()
editForm {
if (target == KeypadTarget.RatioViewer) it.copy(ratioViewer = next, ratioPartner = other) else it.copy(ratioPartner = next, ratioViewer = other)
}
@@ -375,7 +376,7 @@ class ExpenseEditorViewModel @Inject constructor(
val form = u.form ?: return@update u
val next = when (mode) {
SplitMode.DEFAULT -> form.copy(mode = mode)
SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.of(viewerBp), ratioPartner = AmountInput.of(BP_TOTAL - viewerBp))
SplitMode.RATIO -> form.copy(mode = mode, ratioViewer = AmountInput.percent(viewerBp), ratioPartner = AmountInput.percent(BP_TOTAL - viewerBp))
SplitMode.EXACT -> form.copy(mode = mode, exactTyped = me, exactInput = AmountInput())
SplitMode.FULL -> form.copy(mode = mode, fullOn = if (form.payer == me) partner else me)
}
@@ -394,7 +395,7 @@ class ExpenseEditorViewModel @Inject constructor(
/** Ratio slider and presets: whole percent for the viewer. */
override fun onViewerPercent(percent: Int) {
val bp = percent.coerceIn(0, PERCENT) * PERCENT.toLong()
editForm { it.copy(ratioViewer = AmountInput.of(bp), ratioPartner = AmountInput.of(BP_TOTAL - bp)) }
editForm { it.copy(ratioViewer = AmountInput.percent(bp), ratioPartner = AmountInput.percent(BP_TOTAL - bp)) }
clearError(FIELD_SPLIT)
}
@@ -525,15 +526,8 @@ class ExpenseEditorViewModel @Inject constructor(
private const val BP_TOTAL = 10_000L
private const val HALF_BP = 5000
private const val PERCENT = 100
private const val QUICK_ROW = 5
private val SPLIT_TARGETS = setOf(KeypadTarget.RatioViewer, KeypadTarget.RatioPartner, KeypadTarget.Exact)
/** A stored amount as editable text with both decimals when it has öre: 124 760 -> "1247.60", 41 200 -> "412". */
fun moneyInput(ore: Long): AmountInput {
val cents = ore % ORE_PER_KRONA
return AmountInput(if (cents == 0L) (ore / ORE_PER_KRONA).toString() else "${ore / ORE_PER_KRONA}." + cents.toString().padStart(2, '0'))
}
private const val ORE_PER_KRONA = 100L
/** The draft of §14.2.4 from the form: exact derives the other share as amount minus the typed one (AC-8.7). */
@@ -19,7 +19,5 @@
<string name="expense_detail_deleted_body">Den räknas inte längre i saldon eller statistik.</string>
<string name="expense_detail_missing">Utgiften finns inte på telefonen än</string>
<string name="expense_detail_missing_body">Den kan ha lagts till nyss på en annan telefon. Synka för att hämta den.</string>
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
<string name="expense_detail_sync_now">Synka nu</string>
<string name="expense_split_full_on_you">Allt på dig</string>
</resources>
@@ -19,7 +19,5 @@
<string name="expense_detail_deleted_body">It no longer counts in balances or analytics.</string>
<string name="expense_detail_missing">This expense is not on the phone yet</string>
<string name="expense_detail_missing_body">It may have just been added on another phone. Sync to fetch it.</string>
<!-- Not in strings.csv yet (proposed in the M7c report): S-8 "Synka nu" and the split label "Allt på dig". -->
<string name="expense_detail_sync_now">Sync now</string>
<string name="expense_split_full_on_you">All on you</string>
</resources>
@@ -17,6 +17,7 @@ import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
@@ -269,8 +270,9 @@ class ExpenseEditorViewModelTest {
fun editedAmounts_keepBothDecimals() = runTest(dispatcher) {
val (vm, _) = editor(args = mapOf(ExpenseEditorViewModel.ARG_EXPENSE_ID to ExpenseScenarios.icaId.value))
assertEquals("1247.60", vm.state.value.form?.amount?.raw)
assertEquals("412", ExpenseEditorViewModel.moneyInput(41_200).raw)
assertEquals("0.05", ExpenseEditorViewModel.moneyInput(5).raw)
// The feature's own moneyInput is gone: :core:design's AmountInput.of restores money with both decimals (M6b).
assertEquals("412.00", AmountInput.of(41_200).raw)
assertEquals("0.05", AmountInput.of(5).raw)
}
@Test
@@ -53,6 +53,7 @@ import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.SyncStatusPill
import se.hagstedt.kvitt.design.component.UpdateBanner
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.styledStringResource
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
@@ -174,7 +175,7 @@ private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padd
item(key = "hero") { BalanceHero(HeroState.Loading, ui.home.me, words, actions.onSettleUp) }
item(key = "pill") {
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Skeleton(spacing.s1, KvittDimens.pillMinHeight, Modifier.fillMaxWidth(SKELETON_PILL_FRACTION), pill = true)
Skeleton(KvittDimens.skeletonPillWidth, KvittDimens.pillMinHeight, pill = true)
}
}
item(key = "header") { SectionHeader(stringResource(R.string.home_accounts)) }
@@ -234,7 +235,6 @@ private fun HomeList(ui: HomeUi.Ready, partner: User, actions: HomeActions, padd
}
private const val SKELETON_CARDS = 3
private const val SKELETON_PILL_FRACTION = 0.5f
@Composable
private fun UpdateBannerItem(ui: HomeUi.Ready, actions: HomeActions) {
@@ -252,8 +252,7 @@ private fun HomeAccountCard(summary: AccountSummary, partner: User, colorIndex:
private fun expensesLine(count: Int): String {
val today = LocalKvittClock.current.today
val period = today.year.toString() + "-" + today.month.ordinal.plus(1).toString().padStart(2, '0')
// monthYear is "oktober 2026" / "October 2026"; running text needs the month alone.
val month = DateFormatter.monthYear(period, kvittLanguage()).substringBeforeLast(' ')
val month = DateFormatter.monthName(period, kvittLanguage())
return if (count > 0) pluralStringResource(R.plurals.home_account_expenses_month, count, count, month) else stringResource(R.string.home_account_no_expenses_month, month)
}
@@ -263,8 +262,8 @@ private fun WelcomeCard(me: User, partner: User) {
KvittCard(Modifier.fillMaxWidth().semantics(mergeDescendants = true) {}) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.cardTileGap)) {
Box {
PersonAvatar(me, KvittDimens.accountTile)
PersonAvatar(partner, KvittDimens.accountTile, Modifier.padding(start = KvittDimens.accountTile - KvittTheme.spacing.s2))
PersonAvatar(me, KvittDimens.welcomeAvatar)
PersonAvatar(partner, KvittDimens.welcomeAvatar, Modifier.padding(start = KvittDimens.welcomeAvatar - KvittDimens.welcomeAvatarOverlap))
}
Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(stringResource(R.string.home_empty_welcome, partner.displayName), style = MaterialTheme.typography.titleMedium)
@@ -280,7 +279,8 @@ private fun InstallDialog(actions: HomeActions) {
ConfirmDialog(
icon = KvittIcon.InstallMobile,
title = stringResource(DesignR.string.update_permission_title),
body = stringResource(DesignR.string.update_permission_body),
body = null,
styledBody = styledStringResource(DesignR.string.update_permission_body),
confirm = stringResource(DesignR.string.common_continue),
dismiss = stringResource(DesignR.string.update_later),
onConfirm = actions.onInstallContinue,
@@ -57,8 +57,6 @@
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Datum och tid</string>
<string name="home_sync_status_last_attempt_clock">Senaste försöket nekades: klockan</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">utfälld</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">hopfälld</string>
</resources>
@@ -57,8 +57,6 @@
<!-- opens the system date settings -->
<string name="home_sync_status_open_date_settings">Date &amp; time</string>
<string name="home_sync_status_last_attempt_clock">Last attempt refused: the clock</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_expanded">expanded</string>
<!-- PROPOSED (not in strings.csv): TalkBack state of the "Archived (n)" row -->
<string name="home_archived_collapsed">collapsed</string>
</resources>
@@ -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),
@@ -93,8 +93,8 @@ fun AboutContent(state: AboutScreenState?, actions: AboutActions, updateCheckAva
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
Column(Modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s4), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
Box(Modifier.size(KvittTheme.spacing.s12 * 2).background(MaterialTheme.colorScheme.primary, KvittTheme.shapes.avatarLarge).clearAndSetSemantics { }, contentAlignment = Alignment.Center) {
KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittTheme.spacing.s12, tint = MaterialTheme.colorScheme.onPrimary)
Box(Modifier.size(KvittDimens.aboutAppMark).background(MaterialTheme.colorScheme.primary, KvittTheme.shapes.avatarLarge).clearAndSetSemantics { }, contentAlignment = Alignment.Center) {
KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = KvittDimens.aboutAppMarkIcon, tint = MaterialTheme.colorScheme.onPrimary)
}
Text(stringResource(R.string.settings_about_version, about.versionName, about.versionCode), style = MaterialTheme.typography.headlineMedium, textAlign = TextAlign.Center)
Text(stringResource(DesignR.string.common_tagline), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
@@ -246,8 +246,8 @@ private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActi
}
SplitMode.RATIO -> {
val viewerTyped = form.ratioTyped == RatioField.Viewer
val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.of(form.viewerBp.toLong()).display(language)
val partnerText = if (viewerTyped) AmountInput.of((10_000 - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language)
val viewerText = if (viewerTyped) form.ratioInput.display(language) else AmountInput.percent(form.viewerBp.toLong()).display(language)
val partnerText = if (viewerTyped) AmountInput.percent((10_000 - form.viewerBp).toLong()).display(language) else form.ratioInput.display(language)
val focused = when (form.focus) {
EditorFocus.RatioViewer -> RatioField.Viewer
EditorFocus.RatioPartner -> RatioField.Partner
@@ -370,7 +370,7 @@ private fun EditorDialogs(state: RecurringEditorState, actions: RecurringEditorA
EditorDialog.Discard -> ConfirmDialog(
icon = KvittIcon.Close,
title = stringResource(DesignR.string.common_discard_title),
body = "",
body = null,
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions.onDiscard,
@@ -14,6 +14,7 @@ import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.component.AccountChoice
import se.hagstedt.kvitt.design.component.RatioField
import se.hagstedt.kvitt.design.component.quickRowCategories
import se.hagstedt.kvitt.design.component.viewerPercentOf
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.feature.settings.common.UiMessage
@@ -64,7 +65,7 @@ data class RecurringEditorState(
val canSave: Boolean get() = online && !busy && changed && valid && !errors.any
/** The account's default ratio for "Kontots standard 60/40", viewer first. */
val viewerDefaultPercent: Int get() = (account?.defaultShares?.get(options.me.id) ?: BP_TOTAL / 2) / 100
val viewerDefaultPercent: Int get() = viewerPercentOf(account?.defaultShares.orEmpty(), options.me.id)
val dayClampHint: Boolean get() = (form.dayOfMonth ?: 0) >= 29
val resumeHint: Boolean get() = original?.paused == true && !form.paused
}
@@ -139,7 +139,7 @@ data class RecurringForm(
categoryPicked = true,
payer = d.payer,
mode = d.mode,
ratioInput = AmountInput.of((viewerBp ?: BP_TOTAL / 2).toLong()),
ratioInput = AmountInput.percent((viewerBp ?: BP_TOTAL / 2).toLong()),
exactTyped = typed,
exactInput = d.exactOre?.get(typed)?.let { AmountInput.of(it) } ?: AmountInput(),
fullOn = d.fullOn ?: me,
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Keys marked PROPOSED are
not in strings.csv yet; the M7f report proposes them. Generated; change the CSV first. -->
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
first. -->
<resources>
<string name="settings_profile_offline_banner">Kräver anslutning. Profilen kan bara ändras när telefonen är online.</string>
<string name="settings_title">Inställningar</string>
@@ -179,16 +179,10 @@
<string name="settings_about_server_version">Serverversion</string>
<string name="settings_about_server_version_unknown">Okänd</string>
<string name="settings_about_licences">Licenser för öppen källkod</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Arkivera; %1$s category name -->
<string name="settings_category_archived_snackbar">%1$s arkiverades</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Återställ från arkiv -->
<string name="settings_category_unarchived_snackbar">%1$s återställdes</string>
<!-- PROPOSED (M7f report): S-13 recurring row subtitle without templates (design-spec S-13) -->
<string name="settings_recurring_summary_none">Inga</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section (design-spec §3.11 CollapsibleRow) -->
<string name="settings_expanded">utfälld</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section -->
<string name="settings_collapsed">hopfälld</string>
<!-- PROPOSED (M7f report): S-13 partner row before the partner joins -->
<string name="settings_partner_missing">Partner saknas</string>
</resources>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Keys marked PROPOSED are
not in strings.csv yet; the M7f report proposes them. Generated; change the CSV first. -->
<!-- Strings of :feature:settings (M7f), keys from docs/ux/strings.csv (module=settings). Generated; change the CSV
first. -->
<resources>
<string name="settings_profile_offline_banner">Needs connection. Your profile can only be changed online.</string>
<string name="settings_title">Settings</string>
@@ -179,16 +179,10 @@
<string name="settings_about_server_version">Server version</string>
<string name="settings_about_server_version_unknown">Unknown</string>
<string name="settings_about_licences">Open-source licences</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Arkivera; %1$s category name -->
<string name="settings_category_archived_snackbar">%1$s was archived</string>
<!-- PROPOSED (M7f report): S-15 snackbar after Återställ från arkiv -->
<string name="settings_category_unarchived_snackbar">%1$s was restored</string>
<!-- PROPOSED (M7f report): S-13 recurring row subtitle without templates (design-spec S-13) -->
<string name="settings_recurring_summary_none">None</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section (design-spec §3.11 CollapsibleRow) -->
<string name="settings_expanded">expanded</string>
<!-- PROPOSED (M7f report): TalkBack state of the archived section -->
<string name="settings_collapsed">collapsed</string>
<!-- PROPOSED (M7f report): S-13 partner row before the partner joins -->
<string name="settings_partner_missing">Partner has not joined</string>
</resources>
@@ -97,7 +97,7 @@ class RecurringScreenshotTest(private val state: String) {
editor("edit_exact_over", SettingsScenarios.loaded, SettingsScenarios.netflix) { _, vm ->
vm.toggleSplit()
vm.focus(EditorFocus.Exact)
repeat(4) { vm.onKey(AmountKey.Delete) }
vm.onKey(AmountKey.Clear)
vm.type("200")
},
editor("edit_full", SettingsScenarios.loaded, SettingsScenarios.spotify) { _, vm -> vm.toggleSplit() },
@@ -125,7 +125,7 @@ fun SettleContent(state: SettleUiState, actions: SettleActions, modifier: Modifi
ConfirmDialogContent(
icon = KvittIcon.Delete,
title = stringResource(DesignR.string.common_discard_title),
body = "",
body = null,
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions::onDiscard,
@@ -457,7 +457,7 @@ private fun SettleDatePicker(isoDate: String, actions: SettleActions) {
DatePickerDialog(
onDismissRequest = actions::onDismissDialog,
confirmButton = {
KvittButton(resources.getString(DesignR.string.common_save), {
KvittButton(resources.getString(DesignR.string.common_ok), {
picker.selectedDateMillis?.let { actions.onDate(LocalDate.fromEpochDays(it.floorDiv(MILLIS_PER_DAY)).toString()) } ?: actions.onDismissDialog()
}, kind = ButtonKind.Text)
},
@@ -119,6 +119,7 @@ fun SettlementDetailContent(
detail == null -> EmptyState(
title = stringResource(R.string.settle_detail_missing),
body = stringResource(R.string.settle_detail_missing_body),
icon = KvittIcon.SyncProblem,
action = stringResource(R.string.settle_detail_sync_now),
actionIcon = KvittIcon.Sync,
actionKind = ButtonKind.Tonal,
@@ -210,13 +211,13 @@ private fun Hero(detail: SettlementDetailState, modifier: Modifier) {
KvittCard(
modifier.fillMaxWidth().clearAndSetSemantics { contentDescription = spoken },
color = KvittTheme.colors.settlementHero,
contentPadding = PaddingValues(horizontal = KvittTheme.spacing.s5, vertical = KvittTheme.spacing.s5),
contentPadding = PaddingValues(horizontal = KvittDimens.settlementHeroHorizontal, vertical = KvittDimens.settlementHeroVertical),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
PersonAvatar(detail.from, KvittDimens.avatarSettings, shape = AvatarShape.Large)
KvittIconImage(KvittIcon.ArrowForward, null, size = KvittDimens.accountHeaderSettledIcon, tint = MaterialTheme.colorScheme.onSurfaceVariant)
KvittIconImage(KvittIcon.ArrowForward, null, size = KvittDimens.settlementArrow, tint = MaterialTheme.colorScheme.onSurfaceVariant)
PersonAvatar(detail.to, KvittDimens.avatarSettings, shape = AvatarShape.Large)
}
Text(text, style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
proposed in the M7d report and not yet in strings.csv. -->
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle. -->
<resources>
<string name="settle_title">Gör upp</string>
<string name="settle_title_edit">Ändra betalning</string>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle; the last three keys are
proposed in the M7d report and not yet in strings.csv. -->
<!-- :feature:settle strings (S-9, S-10). Source: docs/ux/strings.csv, keys with module=settle. -->
<resources>
<string name="settle_title">Settle up</string>
<string name="settle_title_edit">Edit payment</string>