M7f: feature settings, S-13 to S-15 and S-19 to S-22

Hub, profile (photo picker), categories (drag reorder, editor with D-059 delete reasons), recurring
expenses (list and S-7-style editor with schedule, AC-9.8 default start), devices, invites and about,
against the :core:model interfaces. Scenarios and scripted online failures in fakes/settings.
84 states screenshot-tested in sv/en x light/dark (336 goldens); state-holder unit tests.

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:04:29 +02:00
parent 0b140aa3ad
commit c4ab35f66e
39 changed files with 5525 additions and 14 deletions
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.settings
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Module shell from M2b: the feature's entry point. The owning milestone replaces it with one composable per screen
* that takes its Hilt ViewModel (`hiltViewModel()`) by default and navigation as plain lambdas (android/README.md).
*/
@Composable
fun SettingsEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,152 @@
package se.hagstedt.kvitt.feature.settings.about
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.FactRow
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.UpdateBanner
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.core.design.R as DesignR
/**
* S-22 About. [updateCheckAvailable] false hides "Sök efter uppdatering" (debug builds never check, AC-16.5; `:app`
* passes its build type). [onOpenLicences] opens the open-source licences screen (`:app`).
*/
@Composable
fun AboutScreen(
onBack: () -> Unit,
onOpenLicences: () -> Unit,
updateCheckAvailable: Boolean,
modifier: Modifier = Modifier,
viewModel: AboutViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
AboutContent(state, AboutActions(onBack, onOpenLicences, viewModel::check, viewModel::install, viewModel::later, viewModel::onMessageShown), updateCheckAvailable, modifier)
}
class AboutActions(
val onBack: () -> Unit = {},
val onOpenLicences: () -> Unit = {},
val onCheck: () -> Unit = {},
val onInstall: () -> Unit = {},
val onLater: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/** Stateless S-22. */
@Composable
fun AboutContent(state: AboutScreenState?, actions: AboutActions, updateCheckAvailable: Boolean, modifier: Modifier = Modifier) {
SettingsScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_about_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
message = state?.message,
onMessageShown = actions.onMessageShown,
) { padding ->
if (state == null) return@SettingsScaffold
val about = state.about
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
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)
}
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)
}
if (!state.bannerHidden && state.update !is UpdateState.None) {
UpdateBanner(state.update, state.online, actions.onInstall, actions.onLater, actions.onInstall)
}
SegmentedGroup(3) { item ->
when (item.index) {
0 -> FactRow(item, KvittIcon.Dns, stringResource(R.string.settings_about_server), about.serverUrl)
1 -> FactRow(item, KvittIcon.Memory, stringResource(R.string.settings_about_server_version), about.serverVersion ?: stringResource(R.string.settings_about_server_version_unknown))
else -> LicencesRow(item, actions.onOpenLicences)
}
}
if (updateCheckAvailable) {
Column(Modifier.fillMaxWidth().padding(top = KvittTheme.spacing.s2), horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
KvittButton(
stringResource(R.string.settings_about_check_update),
actions.onCheck,
kind = ButtonKind.Tonal,
size = ButtonSize.Medium,
icon = KvittIcon.SystemUpdate,
enabled = state.online && !state.checking,
)
if (state.checking) CircularProgressIndicator(Modifier.size(KvittDimens.icon), strokeWidth = KvittDimens.placeholderBorder)
}
if (!state.online) NeedsConnectionHint()
}
}
}
}
}
/** "Licenser för öppen källkod" as a fact row with a chevron (design-spec S-22). */
@Composable
private fun LicencesRow(item: SegmentedItemScope, onClick: () -> Unit) {
val label = stringResource(R.string.settings_about_licences)
Row(
Modifier
.fillMaxWidth()
.clip(item.shape)
.background(item.fill)
.clickable(onClick = onClick)
.clearAndSetSemantics { contentDescription = label; role = Role.Button }
.heightIn(min = KvittTheme.spacing.rowMin)
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
KvittIconImage(KvittIcon.Description, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
KvittIconImage(KvittIcon.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -0,0 +1,68 @@
package se.hagstedt.kvitt.feature.settings.about
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.AboutState
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.core.design.R as DesignR
import javax.inject.Inject
/** S-22 (design-spec §4 S-22): version, server, server version (null when `/health` failed), the update banner. */
data class AboutScreenState(val about: AboutState, val update: UpdateState, val online: Boolean, val checking: Boolean, val bannerHidden: Boolean, val message: UiMessage?)
@HiltViewModel
class AboutViewModel @Inject constructor(
about: AboutRepository,
private val update: UpdateRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val local = MutableStateFlow(Local())
val state: StateFlow<AboutScreenState?> = combine(about.about(), update.state, connectivity.online, local) { a, u, online, l ->
AboutScreenState(a, u, online, l.checking, l.bannerHidden, l.message)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
/**
* "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner, or "Du har den senaste
* versionen". `check()` reports no failure (§14.2.4), so a check that finds nothing reads as up to date.
*/
fun check() {
val s = state.value ?: return
if (s.checking) return
if (!s.online) {
local.update { it.copy(message = UiMessage(DesignR.string.common_could_not_reach_server)) }
return
}
local.update { it.copy(checking = true, bannerHidden = false) }
viewModelScope.launch {
update.check()
val found = update.state.first()
local.update { it.copy(checking = false, message = if (found is UpdateState.None) UiMessage(R.string.settings_about_up_to_date) else it.message) }
}
}
fun install() {
viewModelScope.launch { update.install() }
}
/** "Senare" hides the banner for this visit. */
fun later() = local.update { it.copy(bannerHidden = true) }
fun onMessageShown() = local.update { it.copy(message = null) }
private data class Local(val checking: Boolean = false, val bannerHidden: Boolean = false, val message: UiMessage? = null)
}
@@ -0,0 +1,206 @@
package se.hagstedt.kvitt.feature.settings.categories
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.CategoryIconPicker
import se.hagstedt.kvitt.design.component.CategoryTokenTile
import se.hagstedt.kvitt.design.component.ColorSwatchPicker
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.defaultCategoryName
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.SaveAction
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.core.design.R as DesignR
/**
* S-15 category editor. [onClose] after save, delete or the close icon; [onArchived] after "Arkivera" / "Återställ från
* arkiv" (`:app` closes the editor and hands the result to [CategoryListScreen] for its snackbar).
*/
@Composable
fun CategoryEditorScreen(
onClose: () -> Unit,
onArchived: (ArchiveResult) -> Unit,
modifier: Modifier = Modifier,
viewModel: CategoryEditorViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state?.outcome) {
when (val outcome = state?.outcome) {
EditorOutcome.Saved, EditorOutcome.Deleted -> onClose()
is EditorOutcome.Archived -> onArchived(outcome.result)
null -> Unit
}
}
CategoryEditorContent(
state,
CategoryEditorActions(
onClose = onClose,
onName = viewModel::onName,
onIcon = viewModel::onIcon,
onColor = viewModel::onColor,
onResetName = viewModel::resetName,
onSave = viewModel::save,
onToggleArchived = viewModel::toggleArchived,
onAskDelete = viewModel::askDelete,
onDismissDelete = viewModel::dismissDelete,
onDelete = viewModel::delete,
onMessageShown = viewModel::onMessageShown,
),
modifier,
)
}
class CategoryEditorActions(
val onClose: () -> Unit = {},
val onName: (String) -> Unit = {},
val onIcon: (String) -> Unit = {},
val onColor: (ColorToken) -> Unit = {},
val onResetName: () -> Unit = {},
val onSave: () -> Unit = {},
val onToggleArchived: (String) -> Unit = {},
val onAskDelete: () -> Unit = {},
val onDismissDelete: () -> Unit = {},
val onDelete: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/** Stateless S-15 editor; [inlineDialog] renders the delete confirmation in the layout (screenshots, no window). */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun CategoryEditorContent(state: CategoryEditorState?, actions: CategoryEditorActions, modifier: Modifier = Modifier) {
SettingsScaffold(
modifier.fillMaxSize(),
topBar = {
val title = if (state?.isNew != false) R.string.settings_category_editor_new else R.string.settings_category_editor_edit
KvittTopAppBar(stringResource(title), navigation = TopBarNavigation.Close, onNavigation = actions.onClose) {
SaveAction(enabled = state?.canSave == true, onClick = actions.onSave)
}
},
message = state?.message,
onMessageShown = actions.onMessageShown,
) { padding ->
if (state == null) return@SettingsScaffold
val defaultName = state.defaultKey?.let { stringResource(defaultCategoryName(it)) }
val shownName = state.name.trim().ifEmpty { defaultName ?: "" }
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
if (!state.online) NeedsConnectionHint()
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s4)) {
CategoryTokenTile(state.icon, state.color, KvittDimens.tilePreview, archived = state.archived)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
KvittTextField(
value = state.name,
onValueChange = actions.onName,
label = stringResource(DesignR.string.common_name),
error = state.nameError?.let { nameErrorText(it) },
placeholder = defaultName,
onIme = actions.onSave,
enabled = state.online && !state.busy,
)
if (defaultName != null) HintText(stringResource(R.string.settings_category_editor_default_name, defaultName), Modifier.padding(start = KvittTheme.spacing.s4))
if (state.canResetName) {
KvittButton(stringResource(R.string.settings_category_editor_reset_name), actions.onResetName, kind = ButtonKind.Text, icon = KvittIcon.RestartAlt, enabled = state.online)
}
if (state.archived) KvittBadge(stringResource(R.string.settings_category_archived_badge), Modifier.padding(start = KvittTheme.spacing.s4), icon = KvittIcon.Inventory2)
}
}
Text(stringResource(R.string.settings_category_editor_icon), style = MaterialTheme.typography.titleSmall)
CategoryIconPicker(state.icon, state.color, { if (state.online) actions.onIcon(it) })
Text(stringResource(R.string.settings_category_editor_color), style = MaterialTheme.typography.titleSmall)
ColorSwatchPicker(state.color, { if (state.online) actions.onColor(it) })
if (!state.isNew) {
FlowRow(
Modifier.fillMaxWidth().padding(top = KvittTheme.spacing.s2),
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
itemVerticalAlignment = Alignment.CenterVertically,
) {
KvittButton(
stringResource(if (state.archived) R.string.settings_category_editor_unarchive else R.string.settings_category_editor_archive),
{ actions.onToggleArchived(shownName) },
kind = ButtonKind.Outlined,
icon = if (state.archived) KvittIcon.Unarchive else KvittIcon.Inventory2,
enabled = state.online && !state.busy,
)
DeleteControl(state, actions)
}
}
}
if (state.confirmDelete) {
ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(R.string.settings_category_delete_title, shownName),
body = stringResource(R.string.settings_category_delete_body),
confirm = stringResource(DesignR.string.common_delete),
dismiss = stringResource(DesignR.string.common_cancel),
onConfirm = actions.onDelete,
onDismiss = actions.onDismissDelete,
destructive = true,
)
}
}
}
/** D-059: the default lock line, the disabled button with its reason, or the enabled danger button. */
@Composable
private fun DeleteControl(state: CategoryEditorState, actions: CategoryEditorActions) {
when (state.deleteBlock) {
DeleteBlock.Default -> Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
KvittIconImage(KvittIcon.Lock, null, size = KvittDimens.iconHint, tint = MaterialTheme.colorScheme.onSurfaceVariant)
HintText(stringResource(R.string.settings_category_delete_blocked_default_short))
}
DeleteBlock.InUse, DeleteBlock.InUseArchived -> Column {
KvittButton(stringResource(R.string.settings_category_editor_delete), {}, kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = false)
HintText(
stringResource(if (state.deleteBlock == DeleteBlock.InUseArchived) R.string.settings_category_delete_blocked_in_use_archived else R.string.settings_category_delete_blocked_in_use),
Modifier.padding(horizontal = KvittTheme.spacing.s3),
)
}
null -> KvittButton(stringResource(R.string.settings_category_editor_delete), actions.onAskDelete, kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = state.online && !state.busy)
}
}
@Composable
private fun nameErrorText(error: CategoryNameError): String = when (error) {
CategoryNameError.Required -> stringResource(DesignR.string.common_name_required)
CategoryNameError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, CATEGORY_NAME_MAX, CATEGORY_NAME_MAX)
CategoryNameError.Refused -> stringResource(DesignR.string.error_validation)
}
@@ -0,0 +1,227 @@
package se.hagstedt.kvitt.feature.settings.categories
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryIcons
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.DefaultCategoryKey
import se.hagstedt.kvitt.model.OnlineResult
import javax.inject.Inject
/** Why "Ta bort kategori" is not offered (D-059, design-spec S-15). */
enum class DeleteBlock { Default, InUse, InUseArchived }
/** The delete affordance before any tap: from `defaultKey` and `CategoryRepository.deletable(id)` (D-059). */
fun deleteBlock(defaultKey: DefaultCategoryKey?, deletable: Boolean, archived: Boolean): DeleteBlock? = when {
defaultKey != null -> DeleteBlock.Default
deletable -> null
archived -> DeleteBlock.InUseArchived
else -> DeleteBlock.InUse
}
/** A refused delete (`category_in_use`, `category_is_default`) shows the same reason inline. */
fun deleteBlockOf(code: String, archived: Boolean): DeleteBlock? = when (code) {
"category_is_default" -> DeleteBlock.Default
"category_in_use" -> if (archived) DeleteBlock.InUseArchived else DeleteBlock.InUse
else -> null
}
/** §9.6: a category name has 1 to 40 code points (a default category may be empty: its default name). */
const val CATEGORY_NAME_MAX = 40
/** Field errors of the name. */
enum class CategoryNameError { Required, TooLong, Refused }
fun categoryNameError(name: String, isDefault: Boolean): CategoryNameError? {
val trimmed = name.trim()
val length = trimmed.codePointCount(0, trimmed.length)
return when {
length == 0 && !isDefault -> CategoryNameError.Required
length > CATEGORY_NAME_MAX -> CategoryNameError.TooLong
else -> null
}
}
/**
* S-15 editor (design-spec §4 S-15). [original] null for "Ny kategori". [name] is the field: for a default category an
* empty field means the default name (saved as `null`, AC-7.4).
*/
data class CategoryEditorState(
val original: Category?,
val name: String,
val icon: String,
val color: ColorToken,
val nameError: CategoryNameError?,
val deleteBlock: DeleteBlock?,
val online: Boolean,
val busy: Boolean,
val confirmDelete: Boolean,
val message: UiMessage?,
val outcome: EditorOutcome?,
) {
val isNew: Boolean get() = original == null
val defaultKey: DefaultCategoryKey? get() = original?.defaultKey
val archived: Boolean get() = original?.archived == true
/** "Återställ namn" shows for a default category while the field holds a custom name. */
val canResetName: Boolean get() = defaultKey != null && name.isNotBlank()
val changed: Boolean get() = original == null || name.trim() != (original.customName ?: "") || icon != original.icon || color != original.color
val canSave: Boolean get() = online && !busy && changed && nameError == null
}
/** How the editor ends: saved or deleted (back), or archived / unarchived (back with a list snackbar). */
sealed interface EditorOutcome {
data object Saved : EditorOutcome
data object Deleted : EditorOutcome
data class Archived(val result: ArchiveResult) : EditorOutcome
}
@HiltViewModel
class CategoryEditorViewModel @Inject constructor(
savedState: SavedStateHandle,
private val categories: CategoryRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
/** Navigation argument: the category to edit, absent for a new one. */
private val id: CategoryId? = savedState.get<String>(ARG_CATEGORY_ID)?.let { CategoryId(it) }
private val form = MutableStateFlow(Form())
val state: StateFlow<CategoryEditorState?> = combine(
categories.all(),
id?.let { categories.deletable(it) } ?: flowOf(true),
connectivity.online,
form,
) { all, deletable, online, f ->
val original = id?.let { i -> all.firstOrNull { it.id == i } }
if (id != null && original == null) return@combine null
val name = f.name ?: original?.customName ?: ""
val isDefault = original?.defaultKey != null
CategoryEditorState(
original = original,
name = name,
icon = f.icon ?: original?.icon ?: CategoryIcons.ALL.first(),
color = f.color ?: original?.color ?: ColorToken.GREEN,
nameError = f.serverNameError ?: if (f.name != null || f.triedSave) categoryNameError(name, isDefault) else null,
deleteBlock = f.refusedDelete ?: original?.let { deleteBlock(it.defaultKey, deletable, it.archived) },
online = online,
busy = f.busy,
confirmDelete = f.confirmDelete,
message = f.message,
outcome = f.outcome,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun onName(name: String) = form.update { it.copy(name = name, serverNameError = null) }
fun onIcon(icon: String) = form.update { it.copy(icon = icon) }
fun onColor(color: ColorToken) = form.update { it.copy(color = color) }
/** "Återställ namn": the field empties, the default name returns; saved with "Spara" (`name = null`). */
fun resetName() = form.update { it.copy(name = "", serverNameError = null) }
/** "Spara": `update` or `create`; success → back. */
fun save() {
form.update { it.copy(triedSave = true) }
val s = state.value ?: return
if (!s.canSave) return
form.update { it.copy(busy = true) }
viewModelScope.launch {
val name = s.name.trim()
val result: OnlineResult<*> = when (val original = s.original) {
null -> categories.create(name, s.icon, s.color)
else -> categories.update(original.id, if (original.defaultKey != null && name.isEmpty()) null else name, s.icon, s.color)
}
form.update { it.copy(busy = false) }
when {
result is OnlineResult.Ok -> form.update { it.copy(outcome = EditorOutcome.Saved) }
result is OnlineResult.Failed && result.field == "name" -> form.update { it.copy(serverNameError = CategoryNameError.Refused) }
else -> fail(result)
}
}
}
/** "Arkivera" / "Återställ från arkiv": immediate (online), back to the list with a snackbar. */
fun toggleArchived(displayName: String) {
val s = state.value ?: return
val original = s.original ?: return
if (!s.online || s.busy) return
form.update { it.copy(busy = true) }
viewModelScope.launch {
val result = categories.setArchived(original.id, !original.archived)
form.update { it.copy(busy = false) }
if (result is OnlineResult.Ok) {
form.update { it.copy(outcome = EditorOutcome.Archived(ArchiveResult(displayName, !original.archived))) }
} else {
fail(result)
}
}
}
fun askDelete() = form.update { it.copy(confirmDelete = state.value?.let { s -> s.online && s.deleteBlock == null } == true) }
fun dismissDelete() = form.update { it.copy(confirmDelete = false) }
/** Confirmed delete; `category_in_use` / `category_is_default` show the reason inline (D-059). */
fun delete() {
val s = state.value ?: return
val original = s.original ?: return
form.update { it.copy(confirmDelete = false, busy = true) }
viewModelScope.launch {
val result = categories.delete(original.id)
form.update { it.copy(busy = false) }
val code = result.failureCode()
val block = code?.let { deleteBlockOf(it, original.archived) }
when {
result is OnlineResult.Ok -> form.update { it.copy(outcome = EditorOutcome.Deleted) }
block != null -> form.update { it.copy(refusedDelete = block) }
else -> fail(result)
}
}
}
fun onMessageShown() = form.update { it.copy(message = null) }
private fun fail(result: OnlineResult<*>) {
val code = result.failureCode() ?: return
form.update { it.copy(message = failureMessage(code, canRetry = false)) }
}
private data class Form(
val name: String? = null,
val icon: String? = null,
val color: ColorToken? = null,
val serverNameError: CategoryNameError? = null,
val triedSave: Boolean = false,
val refusedDelete: DeleteBlock? = null,
val busy: Boolean = false,
val confirmDelete: Boolean = false,
val message: UiMessage? = null,
val outcome: EditorOutcome? = null,
)
companion object {
/** The `SavedStateHandle` key of the category id (a string; absent for a new category). */
const val ARG_CATEGORY_ID = "categoryId"
}
}
@@ -0,0 +1,285 @@
package se.hagstedt.kvitt.feature.settings.categories
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.CustomAccessibilityAction
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.zIndex
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.CategoryTile
import se.hagstedt.kvitt.design.component.CollapsibleRow
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.categoryName
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
/**
* S-15 Categories list. [archiveResult] is the editor's archive or unarchive, handed back by `:app` when the editor
* closes; it shows as a snackbar once and [onArchiveResultShown] clears it.
*/
@Composable
fun CategoryListScreen(
onBack: () -> Unit,
onOpenCategory: (CategoryId) -> Unit,
onNewCategory: () -> Unit,
modifier: Modifier = Modifier,
archiveResult: ArchiveResult? = null,
onArchiveResultShown: () -> Unit = {},
viewModel: CategoryListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(archiveResult) {
if (archiveResult != null) {
viewModel.onArchiveResult(archiveResult)
onArchiveResultShown()
}
}
CategoryListContent(
state,
CategoryListActions(
onBack = onBack,
onOpen = onOpenCategory,
onNew = onNewCategory,
onToggleArchived = viewModel::toggleArchived,
onStartDrag = viewModel::startDrag,
onDragTo = viewModel::dragTo,
onEndDrag = viewModel::endDrag,
onMove = viewModel::move,
onMessageShown = viewModel::onMessageShown,
),
modifier,
)
}
class CategoryListActions(
val onBack: () -> Unit = {},
val onOpen: (CategoryId) -> Unit = {},
val onNew: () -> Unit = {},
val onToggleArchived: () -> Unit = {},
val onStartDrag: (CategoryId) -> Unit = {},
val onDragTo: (Int) -> Unit = {},
val onEndDrag: () -> Unit = {},
val onMove: (CategoryId, Int) -> Unit = { _, _ -> },
val onMessageShown: () -> Unit = {},
)
/** Stateless S-15 list. */
@Composable
fun CategoryListContent(state: CategoryListState?, actions: CategoryListActions, modifier: Modifier = Modifier) {
SettingsScaffold(
modifier.fillMaxSize(),
topBar = {
KvittTopAppBar(stringResource(R.string.settings_categories_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) {
IconButton(actions.onNew, enabled = state?.online == true) {
KvittIconImage(KvittIcon.Add, stringResource(R.string.settings_categories_new))
}
}
},
message = state?.message,
onMessageShown = actions.onMessageShown,
) { padding ->
if (state == null) return@SettingsScaffold
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1),
) {
if (!state.online) NeedsConnectionHint(text = stringResource(R.string.settings_categories_reorder_offline))
ActiveGroup(state, actions)
CollapsibleRow(
stringResource(R.string.settings_categories_archived, state.archived.size),
expanded = state.archivedExpanded,
expandedState = stringResource(R.string.settings_expanded),
collapsedState = stringResource(R.string.settings_collapsed),
onToggle = actions.onToggleArchived,
)
if (state.archivedExpanded && state.archived.isNotEmpty()) {
SegmentedGroup(state.archived.size) { item ->
CategoryRow(item, state.archived[item.index], state.online, handle = null, onClick = { actions.onOpen(state.archived[item.index].id) })
}
}
Spacer(Modifier.heightIn(min = KvittTheme.spacing.s6))
}
}
}
/** The active group with drag-to-reorder: long-press a row or drag its handle; drop saves (design-spec S-15). */
@Composable
private fun ActiveGroup(state: CategoryListState, actions: CategoryListActions) {
var rowHeight by remember { mutableIntStateOf(1) }
var offset by remember { mutableFloatStateOf(0f) }
val active by rememberUpdatedState(state.active)
val dragging by rememberUpdatedState(state.dragging)
val moveUp = stringResource(R.string.settings_categories_move_up)
val moveDown = stringResource(R.string.settings_categories_move_down)
fun dragBy(delta: Float) {
val id = dragging ?: return
offset += delta
val step = rowHeight.toFloat()
val index = active.indexOfFirst { it.id == id }
if (offset > step / 2 && index < active.lastIndex) {
actions.onDragTo(index + 1)
offset -= step
} else if (offset < -step / 2 && index > 0) {
actions.onDragTo(index - 1)
offset += step
}
}
fun end() {
offset = 0f
actions.onEndDrag()
}
SegmentedGroup(state.active.size) { item ->
val category = state.active[item.index]
val isDragged = category.id == state.dragging
val drag = Modifier.pointerInput(category.id, state.online) {
if (state.online) {
detectDragGestures(onDragStart = { actions.onStartDrag(category.id) }, onDragEnd = ::end, onDragCancel = ::end) { change, amount ->
change.consume()
dragBy(amount.y)
}
}
}
val longPress = Modifier.pointerInput(category.id, state.online) {
if (state.online) {
detectDragGesturesAfterLongPress(onDragStart = { actions.onStartDrag(category.id) }, onDragEnd = ::end, onDragCancel = ::end) { change, amount ->
change.consume()
dragBy(amount.y)
}
}
}
val a11y = if (state.online) {
Modifier.semantics {
customActions = listOf(
CustomAccessibilityAction(moveUp) { actions.onMove(category.id, -1); true },
CustomAccessibilityAction(moveDown) { actions.onMove(category.id, 1); true },
)
}
} else {
Modifier
}
val lifted = if (isDragged) {
Modifier
.zIndex(1f)
.graphicsLayer { translationY = offset }
.shadow(KvittElevation.overlay, item.shape)
} else {
Modifier
}
CategoryRow(
item,
category,
state.online,
handle = if (state.online) DragHandle(drag) else null,
onClick = { actions.onOpen(category.id) },
modifier = lifted.onSizeChanged { rowHeight = it.height.coerceAtLeast(1) }.then(longPress).then(a11y),
lifted = isDragged,
)
}
}
/** The drag gesture of a row's handle. */
private class DragHandle(val gesture: Modifier)
/**
* One category row: drag handle (32 × 48, active and online only; a 32 dp spacer for archived rows), tile 36, name,
* chevron. Offline: not clickable, no chevron.
*/
@Composable
private fun CategoryRow(
item: SegmentedItemScope,
category: Category,
online: Boolean,
handle: DragHandle?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
lifted: Boolean = false,
) {
val name = categoryName(category)
val handleLabel = stringResource(R.string.settings_categories_drag_handle, name)
val fill = if (lifted) MaterialTheme.colorScheme.surfaceContainerHighest else item.fill
Row(
modifier
.fillMaxWidth()
.clip(item.shape)
.background(fill)
.clickable(enabled = online, role = Role.Button, onClick = onClick)
.heightIn(min = KvittTheme.spacing.rowMin)
.padding(start = KvittTheme.spacing.s2, end = KvittTheme.spacing.s3, top = KvittTheme.spacing.s1, bottom = KvittTheme.spacing.s1),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
if (handle != null) {
Box(
Modifier
.size(width = KvittTheme.spacing.s8, height = KvittTheme.spacing.touchTarget)
.then(handle.gesture)
.clearAndSetSemantics { contentDescription = handleLabel; role = Role.Button },
contentAlignment = Alignment.Center,
) {
KvittIconImage(KvittIcon.DragIndicator, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
Spacer(Modifier.width(KvittTheme.spacing.s8))
}
CategoryTile(category, KvittDimens.tileSmall)
Text(name, Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (online) KvittIconImage(KvittIcon.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -0,0 +1,132 @@
package se.hagstedt.kvitt.feature.settings.categories
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import javax.inject.Inject
/**
* S-15 list (design-spec §4 S-15): active categories in `sort_order` (or in the order being dragged), the archived
* section, online state for the drag handles and "Ny kategori", and the snackbar.
*/
data class CategoryListState(
val active: List<Category>,
val archived: List<Category>,
val online: Boolean,
val archivedExpanded: Boolean,
val dragging: CategoryId?,
val message: UiMessage?,
)
/** The editor's archive or unarchive, shown as a snackbar on the list ("Pant arkiverades"). */
data class ArchiveResult(val name: String, val archived: Boolean)
/** [ids] with [id] moved to [to] (clamped). */
fun moveId(ids: List<CategoryId>, id: CategoryId, to: Int): List<CategoryId> {
val from = ids.indexOf(id)
if (from < 0) return ids
val target = to.coerceIn(0, ids.lastIndex)
if (target == from) return ids
return ids.toMutableList().apply { removeAt(from); add(target, id) }
}
@HiltViewModel
class CategoryListViewModel @Inject constructor(
private val categories: CategoryRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val local = MutableStateFlow(Local())
val state: StateFlow<CategoryListState?> = combine(categories.all(), connectivity.online, local) { all, online, l ->
val active = all.filter { !it.archived }.sortedBy { it.sortOrder }
val byId = active.associateBy { it.id }
val ordered = l.order?.mapNotNull { byId[it] }?.takeIf { it.size == active.size } ?: active
CategoryListState(
active = ordered,
archived = all.filter { it.archived }.sortedBy { it.sortOrder },
online = online,
archivedExpanded = l.archivedExpanded,
dragging = l.dragging,
message = l.message,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun toggleArchived() = local.update { it.copy(archivedExpanded = !it.archivedExpanded) }
/** Long-press or the handle picks [id] up (online only). */
fun startDrag(id: CategoryId) {
val current = state.value ?: return
if (!current.online) return
local.update { it.copy(dragging = id, order = current.active.map { c -> c.id }, before = current.active.map { c -> c.id }) }
}
/** The dragged row passes over position [to]. */
fun dragTo(to: Int) = local.update { l ->
val id = l.dragging ?: return@update l
l.copy(order = l.order?.let { moveId(it, id, to) })
}
/** Drop: `reorder(ids)`; a failure brings the old order back with "Kunde inte nå servern" (design-spec S-15). */
fun endDrag() {
val l = local.value
val order = l.order ?: return
if (order == l.before) {
local.update { it.copy(dragging = null, order = null, before = null) }
return
}
local.update { it.copy(dragging = null, before = null) }
commit(order)
}
/** TalkBack "Flytta upp" / "Flytta ned": one step in the list, saved at once. */
fun move(id: CategoryId, delta: Int) {
val current = state.value ?: return
if (!current.online || local.value.dragging != null) return
val ids = current.active.map { it.id }
val moved = moveId(ids, id, ids.indexOf(id) + delta)
if (moved == ids) return
local.update { it.copy(order = moved) }
commit(moved)
}
/** The editor archived or unarchived a category (delivered by `:app` on return). */
fun onArchiveResult(result: ArchiveResult) = local.update {
it.copy(message = UiMessage(if (result.archived) R.string.settings_category_archived_snackbar else R.string.settings_category_unarchived_snackbar, listOf(result.name)))
}
fun onMessageShown() = local.update { it.copy(message = null) }
private fun commit(order: List<CategoryId>) {
viewModelScope.launch {
val result = categories.reorder(order)
local.update { l ->
val message = if (result is OnlineResult.Ok) l.message else result.failureCode()?.let { failureMessage(it, canRetry = false) }
l.copy(order = null, message = message)
}
}
}
private data class Local(
val order: List<CategoryId>? = null,
val before: List<CategoryId>? = null,
val dragging: CategoryId? = null,
val archivedExpanded: Boolean = false,
val message: UiMessage? = null,
)
}
@@ -0,0 +1,118 @@
package se.hagstedt.kvitt.feature.settings.common
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import kotlinx.coroutines.delay
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittSnackbar
import se.hagstedt.kvitt.design.component.snackbarMillis
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.feature.settings.R
/**
* A snackbar the screen shows once (design-spec §1 conventions): [text] from resources with [args]; [retry] adds
* "Försök igen" (`common_retry`) for a repeatable call that failed with a network error. [id] makes repeats distinct.
*/
data class UiMessage(@param:StringRes val text: Int, val args: List<String> = emptyList(), val retry: Boolean = false, val id: Long = nextId()) {
private companion object {
var counter = 0L
fun nextId(): Long {
counter += 1
return counter
}
}
}
/** The §14.3.2 code of a failed online call; `Offline` reads as "needs connection". */
fun OnlineResult<*>.failureCode(): String? = when (this) {
is OnlineResult.Ok -> null
OnlineResult.Offline -> OFFLINE
is OnlineResult.Failed -> code
}
const val OFFLINE = "offline"
const val NETWORK = "network"
/**
* The words for a refused online call (design-spec §1, `strings.csv` `error_*`): network → "Kunde inte nå servern",
* offline → "Kräver anslutning", known §14.3.2 codes → their text, anything else → the generic validation text.
*/
@StringRes
fun failureText(code: String): Int = when (code) {
NETWORK -> DesignR.string.common_could_not_reach_server
OFFLINE -> DesignR.string.common_needs_connection
"household_full" -> DesignR.string.error_household_full
"household_incomplete" -> DesignR.string.error_household_incomplete
"forbidden" -> DesignR.string.error_forbidden
"not_found" -> DesignR.string.error_not_found
"rate_limited" -> DesignR.string.error_rate_limited
"internal", "unavailable", "not_implemented" -> DesignR.string.error_server
"protocol_unsupported" -> DesignR.string.error_app_too_old
"validation_failed", "invalid_request" -> DesignR.string.error_validation
"image_invalid" -> DesignR.string.profile_picture_invalid
"payload_too_large" -> DesignR.string.profile_picture_too_large
"unsupported_media_type" -> DesignR.string.profile_picture_unsupported
"category_in_use" -> R.string.settings_category_delete_blocked_in_use
"category_is_default" -> R.string.settings_category_delete_blocked_default
"cannot_revoke_current_device" -> R.string.settings_devices_cannot_revoke_current
else -> DesignR.string.error_generic_invalid
}
/** The snackbar for a failed call: network failures offer "Försök igen" when [canRetry]. */
fun failureMessage(code: String, canRetry: Boolean = true): UiMessage = UiMessage(failureText(code), retry = canRetry && code == NETWORK)
/**
* The settings screens' scaffold: [KvittScaffold] plus the one-shot snackbar of [message] (design-spec §3.11:
* `inverse-surface`, 4 s, 10 s with "Försök igen"), drawn at the bottom above the bars. The text is resolved while
* shown, so it follows the app language. [onMessageShown] when it times out or its action is used.
*/
@Composable
fun SettingsScaffold(
modifier: Modifier = Modifier,
topBar: @Composable () -> Unit = {},
bottomBar: @Composable () -> Unit = {},
floatingActionButton: @Composable () -> Unit = {},
message: UiMessage? = null,
onMessageShown: () -> Unit = {},
onRetry: () -> Unit = {},
content: @Composable (PaddingValues) -> Unit,
) {
Box(modifier.fillMaxSize()) {
KvittScaffold(Modifier.fillMaxSize(), topBar = topBar, bottomBar = bottomBar, floatingActionButton = floatingActionButton, content = content)
if (message != null) {
LaunchedEffect(message.id) {
delay(snackbarMillis(message.retry))
onMessageShown()
}
val text = stringResource(message.text, *message.args.toTypedArray())
KvittSnackbar(
text,
Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(KvittTheme.spacing.s4)
.semantics { liveRegion = LiveRegionMode.Polite },
action = if (message.retry) stringResource(DesignR.string.common_retry) else null,
onAction = {
onMessageShown()
onRetry()
},
)
}
}
}
@@ -0,0 +1,58 @@
package se.hagstedt.kvitt.feature.settings.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.res.stringResource
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.Banner
import se.hagstedt.kvitt.design.component.BannerKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.core.design.R as DesignR
/** "Spara" in a top app bar: small filled button (design-spec S-14, S-15, S-19). */
@Composable
fun SaveAction(enabled: Boolean, onClick: () -> Unit) {
Box(Modifier.padding(end = KvittTheme.spacing.s2)) {
KvittButton(stringResource(DesignR.string.common_save), onClick, enabled = enabled)
}
}
/**
* The offline Warning banner of S-14, S-19 and S-21: `cloud_off` and the screen's sentence, which already starts with
* "Kräver anslutning." (`settings_*_offline_banner`), so it is the banner's only text.
*/
@Composable
fun OfflineBanner(text: String, modifier: Modifier = Modifier) {
Banner(BannerKind.Warning, KvittIcon.CloudOff, text, modifier)
}
/** A body-small muted hint line (S-19 schedule hints, S-20 footer). */
@Composable
fun HintText(text: String, modifier: Modifier = Modifier) {
Text(text, modifier, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
/** A centred status block: icon 48 and a title-medium line, optional action (S-20 offline and unreachable). */
@Composable
fun CentredStatus(icon: KvittIcon, title: String, modifier: Modifier = Modifier, action: String? = null, onAction: () -> Unit = {}) {
Column(
modifier.fillMaxWidth().padding(vertical = KvittTheme.spacing.s12),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
KvittIconImage(icon, null, size = KvittTheme.spacing.s12, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Text(title, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface)
if (action != null) KvittButton(action, onAction, kind = ButtonKind.Tonal, icon = KvittIcon.Refresh)
}
}
@@ -0,0 +1,191 @@
package se.hagstedt.kvitt.feature.settings.devices
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.BadgeKind
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.Skeleton
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.listDate
import se.hagstedt.kvitt.design.component.personName
import se.hagstedt.kvitt.design.component.relativeTime
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.CentredStatus
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
/** S-20 Devices. */
@Composable
fun DevicesScreen(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: DevicesViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
DevicesContent(state, DevicesActions(onBack, viewModel::load, viewModel::askRevoke, viewModel::dismissRevoke, viewModel::revoke, viewModel::onMessageShown), modifier)
}
class DevicesActions(
val onBack: () -> Unit = {},
val onRetry: () -> Unit = {},
val onAskRevoke: (Device) -> Unit = {},
val onDismissRevoke: () -> Unit = {},
val onRevoke: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/** Stateless S-20. */
@Composable
fun DevicesContent(state: DevicesState?, actions: DevicesActions, modifier: Modifier = Modifier) {
SettingsScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_devices_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
message = state?.message,
onMessageShown = actions.onMessageShown,
) { padding ->
if (state == null) return@SettingsScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
when (val list = state.list) {
DeviceList.Loading -> {
val loading = stringResource(R.string.settings_devices_loading)
SegmentedGroup(2, Modifier.semantics(mergeDescendants = true) { contentDescription = loading }) { item -> SkeletonRow(item) }
}
is DeviceList.Unavailable -> if (list.unreachable) {
CentredStatus(KvittIcon.CloudOff, stringResource(DesignR.string.common_could_not_reach_server), action = stringResource(DesignR.string.common_retry), onAction = actions.onRetry)
} else {
CentredStatus(KvittIcon.CloudOff, stringResource(R.string.settings_devices_offline))
}
is DeviceList.Loaded -> {
SegmentedGroup(list.devices.size) { item ->
val device = list.devices[item.index]
DeviceRow(item, device, canRevoke(device, state.me) && state.online && !state.busy) { actions.onAskRevoke(device) }
}
HintText(stringResource(R.string.settings_devices_cannot_revoke_current), Modifier.padding(horizontal = KvittTheme.spacing.s2))
}
}
}
state.confirm?.let { device -> RevokeDialog(device, state.me, actions) }
}
}
}
/** A device (AC-3.1): tile, name with "Den här enheten", owner, "Parad … · Senast sedd … · Kvitt …", revoke. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DeviceRow(item: SegmentedItemScope, device: Device, revocable: Boolean, onRevoke: () -> Unit) {
val colors = KvittTheme.colors
val dot = stringResource(DesignR.string.common_separator_dot)
val meta = listOfNotNull(
stringResource(R.string.settings_devices_paired, listDate(device.pairedAt.take(DATE_LENGTH))),
stringResource(R.string.settings_devices_last_seen, relativeTime(device.lastSeenAt)),
device.appVersionCode?.let { stringResource(R.string.settings_devices_app_version, it.toString()) },
).joinToString(dot)
Row(
Modifier
.fillMaxWidth()
.background(item.fill, item.shape)
.heightIn(min = KvittTheme.spacing.ledgerRowMin)
.padding(KvittTheme.spacing.s4),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
Box(Modifier.size(KvittDimens.tileLedger).background(colors.categoryContainer(ColorToken.BLUE), KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) {
KvittIconImage(KvittIcon.Smartphone, null, filled = true, size = KvittDimens.settingsTileIcon, tint = colors.category(ColorToken.BLUE))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Column(Modifier.semantics(mergeDescendants = true) {}, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1), itemVerticalAlignment = Alignment.CenterVertically) {
Text(device.name, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
if (device.current) KvittBadge(stringResource(R.string.settings_devices_this_device), kind = BadgeKind.Lantern)
}
Owner(device.user)
Text(meta, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (revocable) KvittButton(stringResource(R.string.settings_devices_revoke), onRevoke, kind = ButtonKind.DangerText, icon = KvittIcon.Block)
}
}
}
/** RFC 3339 instants start with the ISO date. */
private const val DATE_LENGTH = 10
@Composable
private fun Owner(user: User) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
PersonAvatar(user, KvittDimens.avatarSmall)
Text(personName(user), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun SkeletonRow(item: SegmentedItemScope) {
Row(
Modifier.fillMaxWidth().background(item.fill, item.shape).heightIn(min = KvittTheme.spacing.ledgerRowMin).padding(KvittTheme.spacing.s4),
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
Skeleton(KvittDimens.tileLedger, KvittDimens.tileLedger)
Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
Skeleton(KvittDimens.illustrationWidth, KvittTheme.spacing.s4)
Skeleton(KvittDimens.menuMinWidth, KvittTheme.spacing.s3)
}
}
}
/** Design-spec S-20 revoke confirmation (destructive, `phonelink_erase`). */
@Composable
private fun RevokeDialog(device: Device, me: User, actions: DevicesActions) {
ConfirmDialog(
icon = KvittIcon.PhonelinkErase,
title = stringResource(R.string.settings_devices_revoke_title, device.name),
body = if (device.user.id == me.id) stringResource(R.string.settings_devices_revoke_body_own) else stringResource(R.string.settings_devices_revoke_body_partner, device.user.displayName),
confirm = stringResource(R.string.settings_devices_revoke),
dismiss = stringResource(DesignR.string.common_cancel),
onConfirm = actions.onRevoke,
onDismiss = actions.onDismissRevoke,
destructive = true,
)
}
@@ -0,0 +1,107 @@
package se.hagstedt.kvitt.feature.settings.devices
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.feature.settings.common.NETWORK
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.Device
import se.hagstedt.kvitt.model.DeviceRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** The online list of S-20: loading, the devices, or not available (offline, or the server could not be reached). */
sealed interface DeviceList {
data object Loading : DeviceList
data class Loaded(val devices: List<Device>) : DeviceList
data class Unavailable(val unreachable: Boolean) : DeviceList
}
/** S-20 (design-spec §4 S-20, AC-3.1, AC-3.2). Revoked devices are not listed. */
data class DevicesState(val me: User, val partner: User?, val list: DeviceList, val online: Boolean, val confirm: Device?, val busy: Boolean, val message: UiMessage?)
/** AC-3.2, §8.4: the admin may revoke any device but the current one, a member only their own other devices. */
fun canRevoke(device: Device, me: User): Boolean = !device.current && !device.revoked && (me.role == Role.ADMIN || device.user.id == me.id)
@HiltViewModel
class DevicesViewModel @Inject constructor(
private val devices: DeviceRepository,
session: SessionRepository,
profile: ProfileRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val local = MutableStateFlow(Local())
val state: StateFlow<DevicesState?> = combine(session.session, profile.household(), connectivity.online, local) { s, household, online, l ->
val paired = s as? SessionState.Paired ?: return@combine null
val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me
DevicesState(
me = me,
partner = household.firstOrNull { it.id != me.id } ?: paired.partner,
list = if (!online && l.list !is DeviceList.Loaded) DeviceList.Unavailable(unreachable = false) else l.list,
online = online,
confirm = l.confirm,
busy = l.busy,
message = l.message,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
init {
viewModelScope.launch {
connectivity.online.distinctUntilChanged().collect { online -> if (online && local.value.list !is DeviceList.Loaded) load() }
}
}
/** `list()` (online); a network failure shows the unavailable layout with "Försök igen". */
fun load() {
local.update { it.copy(list = DeviceList.Loading) }
viewModelScope.launch {
val result = devices.list()
val list = when (result) {
is OnlineResult.Ok -> DeviceList.Loaded(result.value.filter { d -> !d.revoked })
OnlineResult.Offline -> DeviceList.Unavailable(unreachable = false)
is OnlineResult.Failed -> DeviceList.Unavailable(unreachable = result.code == NETWORK)
}
local.update { it.copy(list = list) }
}
}
fun askRevoke(device: Device) = local.update { it.copy(confirm = device) }
fun dismissRevoke() = local.update { it.copy(confirm = null) }
/** Confirmed revoke → the row goes; a refusal (`cannot_revoke_current_device`, network) → snackbar. */
fun revoke() {
val device = local.value.confirm ?: return
local.update { it.copy(confirm = null, busy = true) }
viewModelScope.launch {
val result = devices.revoke(device.id)
local.update { it.copy(busy = false) }
if (result is OnlineResult.Ok) {
local.update { l -> (l.list as? DeviceList.Loaded)?.let { l.copy(list = DeviceList.Loaded(it.devices.filter { d -> d.id != device.id })) } ?: l }
} else {
result.failureCode()?.let { code -> local.update { it.copy(message = failureMessage(code, canRetry = false)) } }
}
}
}
fun onMessageShown() = local.update { it.copy(message = null) }
private data class Local(val list: DeviceList = DeviceList.Loading, val confirm: Device? = null, val busy: Boolean = false, val message: UiMessage? = null)
}
@@ -0,0 +1,170 @@
package se.hagstedt.kvitt.feature.settings.hub
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AvatarPlaceholder
import se.hagstedt.kvitt.design.component.AvatarShape
import se.hagstedt.kvitt.design.component.KvittCard
import se.hagstedt.kvitt.design.component.KvittDestination
import se.hagstedt.kvitt.design.component.KvittDivider
import se.hagstedt.kvitt.design.component.KvittNavigationBar
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SettingsRow
import se.hagstedt.kvitt.design.component.syncLineText
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.core.design.R as DesignR
/** S-13 Settings hub. Each row pushes its screen; "Synkstatus" opens S-16 (wired by `:app`). */
@Composable
fun SettingsHubScreen(
onOpenProfile: () -> Unit,
onOpenCategories: () -> Unit,
onOpenRecurring: () -> Unit,
onOpenDevices: () -> Unit,
onOpenInvites: () -> Unit,
onOpenSyncStatus: () -> Unit,
onOpenAbout: () -> Unit,
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
viewModel: SettingsHubViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
SettingsHubContent(state, SettingsHubActions(onOpenProfile, onOpenCategories, onOpenRecurring, onOpenDevices, onOpenInvites, onOpenSyncStatus, onOpenAbout, onNavigate), modifier)
}
/** The navigation of S-13. */
class SettingsHubActions(
val onOpenProfile: () -> Unit = {},
val onOpenCategories: () -> Unit = {},
val onOpenRecurring: () -> Unit = {},
val onOpenDevices: () -> Unit = {},
val onOpenInvites: () -> Unit = {},
val onOpenSyncStatus: () -> Unit = {},
val onOpenAbout: () -> Unit = {},
val onNavigate: (KvittDestination) -> Unit = {},
)
/** Stateless S-13; [state] null while the session loads (nothing but the bars). */
@Composable
fun SettingsHubContent(state: SettingsHubState?, actions: SettingsHubActions, modifier: Modifier = Modifier) {
SettingsScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_title)) },
bottomBar = { KvittNavigationBar(KvittDestination.Settings, actions.onNavigate) },
) { padding ->
if (state == null) return@SettingsScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
) {
HouseholdCard(state)
SegmentedGroup(3) { item ->
when (item.index) {
0 -> SettingsRow(item, KvittIcon.Person, ColorToken.ORANGE, stringResource(R.string.settings_profile), actions.onOpenProfile, subtitle = stringResource(R.string.settings_profile_summary))
1 -> SettingsRow(
item, KvittIcon.Category, ColorToken.GREEN, stringResource(R.string.settings_categories), actions.onOpenCategories,
subtitle = pluralStringResource(R.plurals.settings_categories_summary, state.activeCategories, state.activeCategories),
)
else -> SettingsRow(item, KvittIcon.EventRepeat, ColorToken.BROWN, stringResource(R.string.settings_recurring), actions.onOpenRecurring, subtitle = recurringSummary(state))
}
}
val rows = buildList {
add(HubRow.Devices)
state.inviteRow?.let { add(if (it == InviteRow.InvitePartner) HubRow.InvitePartner else HubRow.PairDevice) }
add(HubRow.Sync)
add(HubRow.About)
}
SegmentedGroup(rows.size) { item ->
when (rows[item.index]) {
HubRow.Devices -> SettingsRow(item, KvittIcon.Devices, ColorToken.BLUE, stringResource(R.string.settings_devices), actions.onOpenDevices)
HubRow.InvitePartner -> SettingsRow(item, KvittIcon.PersonAdd, ColorToken.TEAL, stringResource(DesignR.string.common_invite_partner), actions.onOpenInvites)
HubRow.PairDevice -> SettingsRow(item, KvittIcon.AddLink, ColorToken.TEAL, stringResource(R.string.settings_pair_device), actions.onOpenInvites)
HubRow.Sync -> SettingsRow(item, KvittIcon.Sync, ColorToken.PURPLE, stringResource(R.string.settings_sync_status), actions.onOpenSyncStatus, subtitle = syncLineText(state.sync, state.online))
HubRow.About -> SettingsRow(
item, KvittIcon.Info, ColorToken.GREY, stringResource(R.string.settings_about), actions.onOpenAbout,
subtitle = if (state.updateAvailable) stringResource(R.string.settings_update_badge) else stringResource(R.string.settings_about_summary, state.versionName),
)
}
}
}
}
}
}
private enum class HubRow { Devices, InvitePartner, PairDevice, Sync, About }
@Composable
private fun recurringSummary(state: SettingsHubState): String {
if (state.activeTemplates == 0 && state.pausedTemplates == 0) return stringResource(R.string.settings_recurring_summary_none)
val parts = listOfNotNull(
pluralStringResource(R.plurals.settings_recurring_summary_active, state.activeTemplates, state.activeTemplates),
if (state.pausedTemplates > 0) pluralStringResource(R.plurals.settings_recurring_summary_paused, state.pausedTemplates, state.pausedTemplates) else null,
)
return parts.joinToString(stringResource(DesignR.string.common_separator_comma))
}
/** Viewer row, divider, partner row (design-spec S-13 point 1). Two TalkBack nodes. */
@Composable
private fun HouseholdCard(state: SettingsHubState) {
KvittCard(Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = KvittDimens.cardPaddingLarge, vertical = KvittDimens.cardPadding), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
PersonLine(state.me, stringResource(if (state.isAdmin) R.string.settings_role_admin else R.string.settings_role_member))
KvittDivider()
val partner = state.partner
if (partner == null) {
val missing = stringResource(R.string.settings_partner_missing)
Row(Modifier.clearAndSetSemantics { contentDescription = missing }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap)) {
AvatarPlaceholder(KvittDimens.avatarSettings)
Text(missing, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
PersonLine(partner, stringResource(if (partner.role == Role.ADMIN) R.string.settings_role_admin_other else DesignR.string.common_partner))
}
}
}
@Composable
private fun PersonLine(user: User, role: String) {
val spoken = "${user.displayName}${stringResource(DesignR.string.common_separator_comma)}$role"
Row(Modifier.clearAndSetSemantics { contentDescription = spoken }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap)) {
PersonAvatar(user, KvittDimens.avatarSettings, shape = AvatarShape.Large)
Column(Modifier.weight(1f)) {
Text(user.displayName, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(role, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -0,0 +1,101 @@
package se.hagstedt.kvitt.feature.settings.hub
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import se.hagstedt.kvitt.model.AboutRepository
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.SyncRepository
import se.hagstedt.kvitt.model.SyncSummary
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/**
* S-13 (design-spec §4 S-13): the household card, row subtitles and which rows the viewer gets. [partner] null: the
* partner has not joined (AC-5.1). The invite row is the admin's only (AC-2.1, AC-2.2, §8.4).
*/
data class SettingsHubState(
val me: User,
val partner: User?,
val activeCategories: Int,
val activeTemplates: Int,
val pausedTemplates: Int,
val sync: SyncSummary,
val online: Boolean,
val versionName: String,
val updateAvailable: Boolean,
) {
val isAdmin: Boolean get() = me.role == Role.ADMIN
/** "Bjud in partner" while the partner has not joined, else "Para ihop en enhet"; null for a member. */
val inviteRow: InviteRow? get() = when {
!isAdmin -> null
partner == null -> InviteRow.InvitePartner
else -> InviteRow.PairDevice
}
}
enum class InviteRow { InvitePartner, PairDevice }
/** Builds the hub state from the repositories; pure, so it is unit-tested without coroutines. */
fun settingsHubState(
session: SessionState,
household: List<User>,
categories: List<Category>,
templates: List<RecurringTemplate>,
sync: SyncSummary,
online: Boolean,
versionName: String,
update: UpdateState,
): SettingsHubState? {
val paired = session as? SessionState.Paired ?: return null
val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me
val partner = paired.partner?.let { p -> household.firstOrNull { it.id == p.id } ?: p }
return SettingsHubState(
me = me,
partner = partner,
activeCategories = categories.count { !it.archived },
activeTemplates = templates.count { !it.paused },
pausedTemplates = templates.count { it.paused },
sync = sync,
online = online,
versionName = versionName,
updateAvailable = update !is UpdateState.None,
)
}
@HiltViewModel
class SettingsHubViewModel @Inject constructor(
session: SessionRepository,
profile: ProfileRepository,
categories: CategoryRepository,
recurring: RecurringRepository,
sync: SyncRepository,
about: AboutRepository,
update: UpdateRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val people = combine(session.session, profile.household()) { s, h -> s to h }
private val lists = combine(categories.all(), recurring.all()) { c, t -> c to t }
private val status = combine(sync.status(), connectivity.online, about.about(), update.state) { s, o, a, u -> Status(s.summary, o, a.versionName, u) }
val state: StateFlow<SettingsHubState?> = combine(people, lists, status) { (s, h), (c, t), st ->
settingsHubState(s, h, c, t, st.sync, st.online, st.versionName, st.update)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private data class Status(val sync: SyncSummary, val online: Boolean, val versionName: String, val update: UpdateState)
}
@@ -0,0 +1,283 @@
package se.hagstedt.kvitt.feature.settings.invites
import android.content.ClipData
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.InviteCodeDisplay
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittCard
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.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.groupInviteCode
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.normalizeInviteCode
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.model.ColorToken
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.UserId
import kotlin.time.Instant
import se.hagstedt.kvitt.core.design.R as DesignR
/** S-21 Invites (admin only; the hub offers it only to the admin, AC-2.1, §8.4). */
@Composable
fun InvitesScreen(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: InvitesViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val shareMessage = state?.created?.let { invite -> stringResource(R.string.settings_invites_share_message, invite.link.orEmpty(), groupInviteCode(normalizeInviteCode(invite.code.orEmpty()))) }
val shareTitle = stringResource(R.string.settings_invites_share)
InvitesContent(
state,
InvitesActions(
onBack = onBack,
onInvitePartner = viewModel::invitePartner,
onInviteDevice = viewModel::inviteDevice,
onShare = {
if (shareMessage != null) {
val send = Intent(Intent.ACTION_SEND).setType("text/plain").putExtra(Intent.EXTRA_TEXT, shareMessage)
context.startActivity(Intent.createChooser(send, shareTitle))
}
},
onCopy = { code ->
scope.launch {
clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(code, code)))
viewModel.onCopied()
}
},
onAskCancel = viewModel::askCancel,
onDismissCancel = viewModel::dismissCancel,
onCancel = viewModel::cancel,
onRetry = viewModel::onRetry,
onMessageShown = viewModel::onMessageShown,
),
modifier,
)
}
class InvitesActions(
val onBack: () -> Unit = {},
val onInvitePartner: () -> Unit = {},
val onInviteDevice: (UserId) -> Unit = {},
val onShare: () -> Unit = {},
val onCopy: (String) -> Unit = {},
val onAskCancel: (Invite) -> Unit = {},
val onDismissCancel: () -> Unit = {},
val onCancel: () -> Unit = {},
val onRetry: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/** Stateless S-21. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun InvitesContent(state: InvitesState?, actions: InvitesActions, modifier: Modifier = Modifier) {
SettingsScaffold(
modifier.fillMaxSize(),
topBar = {
val title = if (state != null && state.partner == null) stringResource(DesignR.string.common_invite_partner) else stringResource(R.string.settings_invites_title_device)
KvittTopAppBar(title, navigation = TopBarNavigation.Back, onNavigation = actions.onBack)
},
message = state?.message,
onMessageShown = actions.onMessageShown,
onRetry = actions.onRetry,
) { padding ->
if (state == null) return@SettingsScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
if (!state.online) OfflineBanner(stringResource(R.string.settings_invites_offline_banner))
val created = state.created
if (created?.code != null) {
CreatedCard(state, created, actions)
Text(stringResource(R.string.settings_invites_instructions), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = KvittTheme.spacing.s2))
} else {
CreateCard(state, actions)
}
state.pending?.let { pending -> PendingSection(state, pending, actions) }
}
state.confirmCancel?.let {
ConfirmDialog(
icon = KvittIcon.Key,
title = stringResource(R.string.settings_invites_cancel_title),
body = stringResource(R.string.settings_invites_code_hidden),
confirm = stringResource(R.string.settings_invites_cancel),
dismiss = stringResource(DesignR.string.common_close),
onConfirm = actions.onCancel,
onDismiss = actions.onDismissCancel,
destructive = true,
)
}
}
}
}
/** Before an invite is created in this visit: the explanation and the create buttons (AC-2.1, AC-2.2). */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CreateCard(state: InvitesState, actions: InvitesActions) {
val enabled = state.online && !state.busy
KvittCard(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
Text(stringResource(R.string.settings_invites_create_body), style = MaterialTheme.typography.bodyMedium)
FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
val partner = state.partner
if (partner == null) {
KvittButton(stringResource(DesignR.string.common_invite_partner), actions.onInvitePartner, icon = KvittIcon.PersonAdd, enabled = enabled)
} else {
KvittButton(stringResource(R.string.settings_invites_for_me), { actions.onInviteDevice(state.me.id) }, kind = ButtonKind.Tonal, icon = KvittIcon.AddLink, enabled = enabled)
KvittButton(stringResource(R.string.settings_invites_for_partner, partner.displayName), { actions.onInviteDevice(partner.id) }, kind = ButtonKind.Tonal, icon = KvittIcon.AddLink, enabled = enabled)
}
}
}
}
/** The created invite (design-spec S-21 point 2): code, link, expiry, "Dela" and "Kopiera kod". */
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun CreatedCard(state: InvitesState, invite: Invite, actions: InvitesActions) {
val scheme = MaterialTheme.colorScheme
val content = scheme.onTertiaryContainer
val title = if (invite.kind == InviteKind.PARTNER) stringResource(R.string.settings_invites_new_partner) else stringResource(R.string.settings_invites_new_device_for, state.userOf(invite.targetUser)?.displayName.orEmpty())
KvittCard(
Modifier.fillMaxWidth(),
color = scheme.tertiaryContainer,
contentPadding = PaddingValues(KvittDimens.cardPaddingLarge),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
Row(Modifier.semantics { heading() }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
KvittIconImage(KvittIcon.Key, null, filled = true, tint = content)
Text(title, style = MaterialTheme.typography.titleMedium, color = content)
}
Text(stringResource(DesignR.string.common_invite_code), style = MaterialTheme.typography.labelMedium, color = content)
InviteCodeDisplay(invite.code.orEmpty())
Text(invite.link.orEmpty(), Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall.copy(lineBreak = LineBreak.Simple), color = content, textAlign = TextAlign.Center)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.CenterHorizontally), verticalAlignment = Alignment.CenterVertically) {
KvittIconImage(KvittIcon.Schedule, null, tint = content)
Text(stringResource(R.string.settings_invites_valid_until, expiry(invite.expiresAt)), style = MaterialTheme.typography.bodyMedium, color = content)
}
FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
KvittButton(stringResource(R.string.settings_invites_share), actions.onShare, kind = ButtonKind.Tertiary, icon = KvittIcon.Share)
KvittButton(stringResource(R.string.settings_invites_copy_code), { actions.onCopy(groupInviteCode(normalizeInviteCode(invite.code.orEmpty()))) }, kind = ButtonKind.Outlined, icon = KvittIcon.ContentCopy, contentColor = content)
}
}
}
/** "Väntande inbjudningar" with a cancel per row (AC-2.11). */
@Composable
private fun PendingSection(state: InvitesState, pending: List<Invite>, actions: InvitesActions) {
Text(
stringResource(R.string.settings_invites_pending),
Modifier.padding(start = KvittTheme.spacing.s2, top = KvittTheme.spacing.s3).semantics { heading() },
style = MaterialTheme.typography.titleSmall,
)
if (pending.isEmpty()) {
HintText(stringResource(R.string.settings_invites_none), Modifier.padding(horizontal = KvittTheme.spacing.s2))
return
}
SegmentedGroup(pending.size) { item -> PendingRow(item, state, pending[item.index], actions) }
HintText(stringResource(R.string.settings_invites_code_hidden), Modifier.padding(horizontal = KvittTheme.spacing.s2))
}
@Composable
private fun PendingRow(item: SegmentedItemScope, state: InvitesState, invite: Invite, actions: InvitesActions) {
val colors = KvittTheme.colors
val title = if (invite.kind == InviteKind.PARTNER) stringResource(R.string.settings_invites_kind_partner) else stringResource(R.string.settings_invites_kind_device, state.userOf(invite.targetUser)?.displayName.orEmpty())
val created = stringResource(R.string.settings_invites_created, createdAt(invite.createdAt))
val valid = stringResource(R.string.settings_invites_valid_until, expiry(invite.expiresAt))
val line = created + stringResource(DesignR.string.common_separator_dot) + valid
val spoken = title + stringResource(DesignR.string.common_separator_comma) + valid
Row(
Modifier.fillMaxWidth().background(item.fill, item.shape).heightIn(min = KvittTheme.spacing.ledgerRowMin).padding(horizontal = KvittTheme.spacing.s4, vertical = KvittTheme.spacing.s3),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
Box(Modifier.size(KvittDimens.tileLedger).background(colors.categoryContainer(ColorToken.YELLOW), KvittTheme.shapes.categoryTile), contentAlignment = Alignment.Center) {
KvittIconImage(KvittIcon.Key, null, filled = true, size = KvittDimens.settingsTileIcon, tint = colors.category(ColorToken.YELLOW))
}
Column(Modifier.weight(1f).clearAndSetSemantics { contentDescription = spoken }) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(line, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
val cancelLabel = stringResource(R.string.settings_invites_cancel)
KvittButton(
stringResource(DesignR.string.common_cancel),
{ actions.onAskCancel(invite) },
Modifier.clearAndSetSemantics { contentDescription = cancelLabel },
kind = ButtonKind.Text,
enabled = state.online && !state.busy,
)
}
}
/** "tis 20 okt. 14:02" (§9.8 weekday, list date and time). */
@Composable
private fun expiry(rfc3339: String): String {
val clock = LocalKvittClock.current
return DateFormatter.weekdayDateTime(Instant.parse(rfc3339), clock.zone, clock.today, kvittLanguage())
}
/** "idag 14:02" for today, else "16 okt. 18:30". */
@Composable
private fun createdAt(rfc3339: String): String {
val clock = LocalKvittClock.current
val instant = Instant.parse(rfc3339)
val language = kvittLanguage()
val local = DateFormatter.listDateTime(instant, clock.zone, clock.today, language)
val sameDay = DateFormatter.listDate(clock.today, clock.today, language) == local.substringBeforeLast(' ')
return if (sameDay) stringResource(DesignR.string.time_today_at, DateFormatter.time(instant, clock.zone)) else local
}
@@ -0,0 +1,145 @@
package se.hagstedt.kvitt.feature.settings.invites
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import se.hagstedt.kvitt.model.UserId
import javax.inject.Inject
/**
* S-21 (design-spec §4 S-21, admin only; AC-2.1, AC-2.2, AC-2.11). [created] is the invite made in this visit, the
* only time its code and link exist; leaving the screen drops it. [pending] null: not loaded (offline or failed).
*/
data class InvitesState(
val me: User,
val partner: User?,
val online: Boolean,
val pending: List<Invite>?,
val created: Invite?,
val busy: Boolean,
val confirmCancel: Invite?,
val message: UiMessage?,
)
/** Who a device invite is for, by id. */
fun InvitesState.userOf(id: UserId?): User? = listOfNotNull(me, partner).firstOrNull { it.id == id }
@HiltViewModel
class InvitesViewModel @Inject constructor(
private val invites: InviteRepository,
session: SessionRepository,
profile: ProfileRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val local = MutableStateFlow(Local())
private var retry: (() -> Unit)? = null
val state: StateFlow<InvitesState?> = combine(session.session, profile.household(), connectivity.online, local) { s, household, online, l ->
val paired = s as? SessionState.Paired ?: return@combine null
val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me
InvitesState(
me = me,
partner = household.firstOrNull { it.id != me.id } ?: paired.partner,
online = online,
pending = if (online) l.pending else null,
created = l.created,
busy = l.busy,
confirmCancel = l.confirmCancel,
message = l.message,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
init {
viewModelScope.launch {
connectivity.online.distinctUntilChanged().collect { online -> if (online) load() }
}
}
/** Pending invites (online; codes are never listed). */
fun load() {
viewModelScope.launch {
val result = invites.list()
if (result is OnlineResult.Ok) local.update { it.copy(pending = result.value.sortedBy { i -> i.createdAt }) }
}
}
/** "Bjud in partner" (AC-2.1). */
fun invitePartner() = create(InviteKind.PARTNER, null)
/** "För mig" / "För Anna" (AC-2.2). */
fun inviteDevice(user: UserId) = create(InviteKind.DEVICE, user)
private fun create(kind: InviteKind, target: UserId?) {
if (local.value.busy) return
retry = { create(kind, target) }
local.update { it.copy(busy = true, created = null) }
viewModelScope.launch {
val result = invites.create(kind, target)
local.update { it.copy(busy = false) }
when (result) {
is OnlineResult.Ok -> {
local.update { it.copy(created = result.value) }
load()
}
else -> result.failureCode()?.let { code -> local.update { it.copy(message = failureMessage(code)) } }
}
}
}
/** "Kopiera kod" done: "Koden kopierades". */
fun onCopied() = local.update { it.copy(message = UiMessage(R.string.settings_invites_code_copied)) }
fun askCancel(invite: Invite) = local.update { it.copy(confirmCancel = invite) }
fun dismissCancel() = local.update { it.copy(confirmCancel = null) }
/** Confirmed "Avbryt": `cancel(id)`; the row goes. */
fun cancel() {
val invite = local.value.confirmCancel ?: return
local.update { it.copy(confirmCancel = null, busy = true) }
viewModelScope.launch {
val result = invites.cancel(invite.id)
local.update { it.copy(busy = false) }
if (result is OnlineResult.Ok) {
local.update { l -> l.copy(pending = l.pending?.filter { it.id != invite.id }, created = l.created?.takeIf { it.id != invite.id }) }
} else {
result.failureCode()?.let { code -> local.update { it.copy(message = failureMessage(code, canRetry = false)) } }
}
}
}
fun onRetry() {
retry?.invoke()
}
fun onMessageShown() = local.update { it.copy(message = null) }
private data class Local(
val pending: List<Invite>? = null,
val created: Invite? = null,
val busy: Boolean = false,
val confirmCancel: Invite? = null,
val message: UiMessage? = null,
)
}
@@ -0,0 +1,173 @@
package se.hagstedt.kvitt.feature.settings.profile
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AvatarShape
import se.hagstedt.kvitt.design.component.AvatarState
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.feature.settings.common.SaveAction
import java.io.IOException
import se.hagstedt.kvitt.core.design.R as DesignR
/** S-14 Profile. [onBack] after a saved name and on the back arrow. */
@Composable
fun ProfileScreen(onBack: () -> Unit, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
scope.launch {
val picked = withContext(Dispatchers.IO) { readPicture(context, uri) }
if (picked == null) viewModel.onPictureUnreadable() else viewModel.onPicture(picked.first, picked.second)
}
}
}
LaunchedEffect(state?.done) { if (state?.done == true) onBack() }
ProfileContent(
state,
ProfileActions(
onBack = onBack,
onName = viewModel::onName,
onSave = viewModel::save,
onPickPicture = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
onRemovePicture = viewModel::removePicture,
onRetry = viewModel::onRetry,
onMessageShown = viewModel::onMessageShown,
),
modifier,
)
}
/** The picked image's bytes and MIME type, or null when the file cannot be read. */
private fun readPicture(context: Context, uri: Uri): Pair<ByteArray, String>? {
val resolver = context.contentResolver
val type = resolver.getType(uri) ?: return null
return try {
resolver.openInputStream(uri)?.use { it.readBytes() }?.let { it to type }
} catch (e: IOException) {
Log.w("Profile", "picture unreadable: ${e.javaClass.simpleName}")
null
} catch (e: SecurityException) {
Log.w("Profile", "picture not permitted: ${e.javaClass.simpleName}")
null
}
}
class ProfileActions(
val onBack: () -> Unit = {},
val onName: (String) -> Unit = {},
val onSave: () -> Unit = {},
val onPickPicture: () -> Unit = {},
val onRemovePicture: () -> Unit = {},
val onRetry: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/** Stateless S-14. */
@Composable
fun ProfileContent(state: ProfileState?, actions: ProfileActions, modifier: Modifier = Modifier) {
SettingsScaffold(
modifier.fillMaxSize(),
topBar = {
KvittTopAppBar(stringResource(R.string.settings_profile), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) {
SaveAction(enabled = state?.canSave == true, onClick = actions.onSave)
}
},
message = state?.message,
onMessageShown = actions.onMessageShown,
onRetry = actions.onRetry,
) { padding ->
if (state == null) return@SettingsScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter),
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
) {
if (!state.online) OfflineBanner(stringResource(R.string.settings_profile_offline_banner))
Column(
Modifier.fillMaxWidth().padding(top = KvittTheme.spacing.s5, bottom = KvittTheme.spacing.detailGap),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
PersonAvatar(
state.me,
KvittDimens.avatarProfile,
shape = AvatarShape.Large,
state = if (state.uploading) AvatarState.Uploading else AvatarState.Idle,
contentDescription = state.me.displayName,
)
KvittButton(
stringResource(if (state.hasPicture) DesignR.string.profile_change_picture else DesignR.string.profile_add_picture),
actions.onPickPicture,
kind = ButtonKind.Tonal,
icon = KvittIcon.PhotoLibrary,
enabled = state.pictureEnabled,
)
if (state.hasPicture) {
KvittButton(stringResource(DesignR.string.profile_remove_picture), actions.onRemovePicture, kind = ButtonKind.Text, icon = KvittIcon.Delete, enabled = state.pictureEnabled)
}
}
KvittTextField(
value = state.name,
onValueChange = actions.onName,
label = stringResource(DesignR.string.common_name),
error = state.nameError?.let { nameErrorText(it) },
supporting = state.partner?.let { stringResource(R.string.settings_profile_hint, it.displayName) },
capitalization = KeyboardCapitalization.Words,
onIme = actions.onSave,
enabled = state.online && !state.saving,
)
}
}
}
}
@Composable
private fun nameErrorText(error: NameError): String = when (error) {
NameError.Required -> stringResource(DesignR.string.common_name_required)
is NameError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, error.max, error.max)
NameError.Refused -> stringResource(DesignR.string.error_validation)
}
@@ -0,0 +1,153 @@
package se.hagstedt.kvitt.feature.settings.profile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
import se.hagstedt.kvitt.core.design.R as DesignR
/** Why the name field is refused (§9.6: 1 to 40 code points). */
sealed interface NameError {
data object Required : NameError
data class TooLong(val max: Int) : NameError
data object Refused : NameError // validation_failed on display_name from the server
}
/** §9.6 limit of a display name. */
const val NAME_MAX = 40
/** The local check of a display name, or null when it may be sent. */
fun nameError(name: String): NameError? {
val trimmed = name.trim()
val length = trimmed.codePointCount(0, trimmed.length)
return when {
length == 0 -> NameError.Required
length > NAME_MAX -> NameError.TooLong(NAME_MAX)
else -> null
}
}
/** S-14 (design-spec §4 S-14): own name and picture, both online only (AC-4.2, AC-4.3, §9.9). */
data class ProfileState(
val me: User,
val partner: User?,
val name: String,
val nameError: NameError?,
val online: Boolean,
val uploading: Boolean,
val saving: Boolean,
val message: UiMessage?,
val done: Boolean,
) {
val hasPicture: Boolean get() = me.pictureVersion != null
val canSave: Boolean get() = online && !saving && !uploading && nameError == null && name.trim() != me.displayName
val pictureEnabled: Boolean get() = online && !uploading
}
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val profile: ProfileRepository,
session: SessionRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val form = MutableStateFlow(Form())
private var retry: (() -> Unit)? = null
val state: StateFlow<ProfileState?> = combine(session.session, profile.household(), connectivity.online, form) { s, household, online, f ->
val paired = s as? SessionState.Paired ?: return@combine null
val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me
val partner = household.firstOrNull { it.id != me.id } ?: paired.partner
val name = f.name ?: me.displayName
ProfileState(
me = me,
partner = partner,
name = name,
nameError = f.serverNameError ?: if (f.name != null) nameError(name) else null,
online = online,
uploading = f.uploading,
saving = f.saving,
message = f.message,
done = f.done,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
fun onName(name: String) = form.update { it.copy(name = name, serverNameError = null) }
/** "Spara": `setDisplayName`; success returns to the hub without a snackbar (design-spec S-14). */
fun save() {
val current = state.value ?: return
if (!current.canSave) return
form.update { it.copy(saving = true) }
viewModelScope.launch {
val result = profile.setDisplayName(current.name.trim())
form.update { it.copy(saving = false) }
when {
result is OnlineResult.Ok -> form.update { it.copy(done = true) }
result is OnlineResult.Failed && result.code == "validation_failed" -> form.update { it.copy(serverNameError = NameError.Refused) }
else -> fail(result) { save() }
}
}
}
/** A picture from the photo picker (AC-4.3): applied on success without "Spara". */
fun onPicture(bytes: ByteArray, mimeType: String) {
retry = { onPicture(bytes, mimeType) }
upload { profile.setPicture(bytes, mimeType) }
}
/** "Ta bort bild": no question asked, adding again undoes it (design-spec S-14). */
fun removePicture() {
retry = { removePicture() }
upload { profile.removePicture() }
}
/** The picked file could not be read on the phone. */
fun onPictureUnreadable() = form.update { it.copy(message = UiMessage(DesignR.string.profile_picture_failed)) }
fun onRetry() {
retry?.invoke()
}
fun onMessageShown() = form.update { it.copy(message = null) }
private fun upload(call: suspend () -> OnlineResult<Unit>) {
if (form.value.uploading) return
form.update { it.copy(uploading = true) }
viewModelScope.launch {
val result = call()
form.update { it.copy(uploading = false) }
if (result !is OnlineResult.Ok) fail(result, null)
}
}
private fun fail(result: OnlineResult<*>, again: (() -> Unit)?) {
val code = result.failureCode() ?: return
if (again != null) retry = again
form.update { it.copy(message = failureMessage(code)) }
}
private data class Form(
val name: String? = null,
val serverNameError: NameError? = null,
val uploading: Boolean = false,
val saving: Boolean = false,
val message: UiMessage? = null,
val done: Boolean = false,
)
}
@@ -0,0 +1,34 @@
package se.hagstedt.kvitt.feature.settings.recurring
/** `YYYY-MM` periods (§14.2.4 `startPeriod`, `endPeriod`) as plain arithmetic. */
object Periods {
fun of(year: Int, month: Int): String = "$year-${month.toString().padStart(2, '0')}"
fun year(period: String): Int = period.substringBefore('-').toInt()
fun month(period: String): Int = period.substringAfter('-').take(2).toInt()
/** [period] moved by [months] (negative: back). */
fun plus(period: String, months: Int): String {
val index = year(period) * 12 + (month(period) - 1) + months
return of(Math.floorDiv(index, 12), Math.floorMod(index, 12) + 1)
}
/** The month of an ISO date `YYYY-MM-DD`. */
fun ofDate(isoDate: String): String = isoDate.take(7)
/**
* AC-9.8: the default first month is the current month when [dayOfMonth] is today or later (phone's zone), else the
* next month. Without a valid day the current month.
*/
fun defaultStart(today: String, dayOfMonth: Int?): String {
val current = ofDate(today)
val day = today.substring(8, 10).toInt()
return if (dayOfMonth == null || dayOfMonth >= day) current else plus(current, 1)
}
/** AC-9.8: at most 12 months back (backfill). */
fun earliestStart(today: String): String = plus(ofDate(today), -MAX_BACKFILL_MONTHS)
const val MAX_BACKFILL_MONTHS = 12
}
@@ -0,0 +1,451 @@
package se.hagstedt.kvitt.feature.settings.recurring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.window.Dialog
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittElevation
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AccountChip
import se.hagstedt.kvitt.design.component.AccountPicker
import se.hagstedt.kvitt.design.component.AmountDisplay
import se.hagstedt.kvitt.design.component.AmountKeypad
import se.hagstedt.kvitt.design.component.ButtonKind
import se.hagstedt.kvitt.design.component.CategoryGrid
import se.hagstedt.kvitt.design.component.CategoryQuickRow
import se.hagstedt.kvitt.design.component.ConfirmDialog
import se.hagstedt.kvitt.design.component.DescriptionField
import se.hagstedt.kvitt.design.component.EditorChipRow
import se.hagstedt.kvitt.design.component.ExactSplitEditor
import se.hagstedt.kvitt.design.component.FullSplitPicker
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.NotesField
import se.hagstedt.kvitt.design.component.PayerChip
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.RatioEditor
import se.hagstedt.kvitt.design.component.RatioField
import se.hagstedt.kvitt.design.component.SplitBar
import se.hagstedt.kvitt.design.component.SplitChip
import se.hagstedt.kvitt.design.component.SplitPreview
import se.hagstedt.kvitt.design.component.SplitSection
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.defaultModeLabel
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.splitModeLabel
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.HintText
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.feature.settings.common.SaveAction
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.UserId
import se.hagstedt.kvitt.core.design.R as DesignR
/** S-19 recurring editor. [onClose] after save, delete, discard or close without changes. */
@Composable
fun RecurringEditorScreen(onClose: () -> Unit, modifier: Modifier = Modifier, viewModel: RecurringEditorViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(state?.done) { if (state?.done == true) onClose() }
RecurringEditorContent(
state,
RecurringEditorActions(
onClose = viewModel::close,
onSave = viewModel::save,
onKey = viewModel::onKey,
onFocus = viewModel::focus,
onDescription = viewModel::onDescription,
onNotes = viewModel::onNotes,
onOpenNotes = viewModel::openNotes,
onCategory = viewModel::onCategory,
onAccount = viewModel::onAccount,
onTogglePayer = viewModel::togglePayer,
onToggleSplit = viewModel::toggleSplit,
onMode = viewModel::onMode,
onViewerPercent = viewModel::onViewerPercent,
onRatioFocus = viewModel::onRatioFocus,
onExactTyped = viewModel::onExactTyped,
onFullOn = viewModel::onFullOn,
onDay = viewModel::onDay,
onPaused = viewModel::onPaused,
onOpenSheet = viewModel::openSheet,
onCloseSheet = viewModel::closeSheet,
onOpenMonth = viewModel::openMonth,
onMonth = viewModel::onMonth,
onDismissDialog = viewModel::dismissDialog,
onDiscard = viewModel::discard,
onAskDelete = viewModel::askDelete,
onDelete = viewModel::delete,
onRetry = viewModel::onRetry,
onMessageShown = viewModel::onMessageShown,
),
modifier,
)
}
class RecurringEditorActions(
val onClose: () -> Unit = {},
val onSave: () -> Unit = {},
val onKey: (AmountKey) -> Unit = {},
val onFocus: (EditorFocus) -> Unit = {},
val onDescription: (String) -> Unit = {},
val onNotes: (String) -> Unit = {},
val onOpenNotes: () -> Unit = {},
val onCategory: (CategoryId) -> Unit = {},
val onAccount: (AccountId) -> Unit = {},
val onTogglePayer: () -> Unit = {},
val onToggleSplit: () -> Unit = {},
val onMode: (SplitMode) -> Unit = {},
val onViewerPercent: (Int) -> Unit = {},
val onRatioFocus: (RatioField) -> Unit = {},
val onExactTyped: (UserId) -> Unit = {},
val onFullOn: (UserId) -> Unit = {},
val onDay: (String) -> Unit = {},
val onPaused: (Boolean) -> Unit = {},
val onOpenSheet: (EditorSheet) -> Unit = {},
val onCloseSheet: () -> Unit = {},
val onOpenMonth: (MonthField) -> Unit = {},
val onMonth: (MonthField, String?) -> Unit = { _, _ -> },
val onDismissDialog: () -> Unit = {},
val onDiscard: () -> Unit = {},
val onAskDelete: () -> Unit = {},
val onDelete: () -> Unit = {},
val onRetry: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/** Stateless S-19 editor. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecurringEditorContent(state: RecurringEditorState?, actions: RecurringEditorActions, modifier: Modifier = Modifier) {
val keypad = state != null && state.form.focus != EditorFocus.None
SettingsScaffold(
modifier.fillMaxSize(),
topBar = {
val title = if (state?.isNew != false) R.string.settings_recurring_editor_new else R.string.settings_recurring_editor_edit
KvittTopAppBar(stringResource(title), navigation = TopBarNavigation.Close, onNavigation = actions.onClose) {
SaveAction(enabled = state?.canSave == true, onClick = actions.onSave)
}
},
bottomBar = {
if (keypad) AmountKeypad(actions.onKey, Modifier.navigationBarsPadding(), compact = state.form.splitOpen)
},
message = state?.message,
onMessageShown = actions.onMessageShown,
onRetry = actions.onRetry,
) { padding ->
if (state == null) return@SettingsScaffold
ProvideHousehold(state.options.me, state.options.partner) {
EditorBody(state, actions, Modifier.padding(padding))
when (state.sheet) {
EditorSheet.Category -> ModalBottomSheet(actions.onCloseSheet) {
CategoryGrid(state.options.categories, state.form.categoryId, { actions.onCategory(it.id) }, Modifier.padding(KvittTheme.spacing.gutter))
}
EditorSheet.Account -> ModalBottomSheet(actions.onCloseSheet) {
AccountPicker(state.choices, state.options.partner, state.form.accountId, { actions.onAccount(it.summary.account.id) }, Modifier.padding(KvittTheme.spacing.gutter))
}
null -> Unit
}
EditorDialogs(state, actions)
}
}
}
@Composable
private fun EditorBody(state: RecurringEditorState, actions: RecurringEditorActions, modifier: Modifier) {
val language = kvittLanguage()
val form = state.form
val me = state.options.me
val partner = state.options.partner
Column(
modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
) {
if (!state.online) OfflineBanner(stringResource(R.string.settings_recurring_offline_banner))
AmountDisplay(
form.amount.display(language),
focused = form.focus == EditorFocus.Amount,
error = state.errors.amount?.let { stringResource(if (it == AmountError.Required) DesignR.string.error_amount_required else DesignR.string.error_validation) },
onClick = { actions.onFocus(EditorFocus.Amount) },
)
DescriptionField(
form.description,
actions.onDescription,
error = state.errors.description?.let { descriptionError(it) },
onDone = { actions.onFocus(EditorFocus.None) },
modifier = Modifier.onFocusChanged { if (it.isFocused) actions.onFocus(EditorFocus.None) },
)
CategoryQuickRow(state.quickRow, form.categoryId, { actions.onCategory(it.id) }, { actions.onOpenSheet(EditorSheet.Category) })
val payer = if ((form.payer ?: me.id) == me.id) me else partner
val viewerPercent = state.viewerDefaultPercent
val defaultLabel = defaultModeLabel(viewerPercent)
EditorChipRow {
AccountChip(state.account?.name.orEmpty(), state.accountColorIndex, { actions.onOpenSheet(EditorSheet.Account) })
PayerChip(payer, if (payer == me) partner else me, actions.onTogglePayer)
SplitChip(
if (form.mode == SplitMode.RATIO) stringResource(DesignR.string.editor_split_mode_ratio) + " " + stringResource(DesignR.string.common_ratio, (form.viewerBp / 100).toString(), ((10_000 - form.viewerBp) / 100).toString()) else splitModeLabel(form.mode, defaultLabel),
form.splitOpen,
actions.onToggleSplit,
)
}
if (form.splitOpen) {
SplitSection(form.mode, defaultLabel, actions.onMode, actions.onToggleSplit) {
when (form.mode) {
SplitMode.DEFAULT -> Column(verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
SplitBar(viewerPercent.toFloat())
SplitPreview(state.shares, me, partner)
}
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 focused = when (form.focus) {
EditorFocus.RatioViewer -> RatioField.Viewer
EditorFocus.RatioPartner -> RatioField.Partner
else -> null
}
RatioEditor(me, partner, form.viewerBp / 100, viewerText, partnerText, focused, actions.onViewerPercent, actions.onRatioFocus, actions.onViewerPercent, state.shares)
}
SplitMode.EXACT -> {
val typed = form.exactTyped ?: me.id
val typedOre = form.exactInput.ore
ExactSplitEditor(
me, partner, typed, form.exactInput.display(language),
derivedOre = typedOre?.let { t -> form.amount.ore?.let { (it - t).takeIf { d -> d >= 0 } } },
focused = form.focus == EditorFocus.Exact,
amountOre = form.amount.ore,
typedOre = typedOre,
onTypedChange = actions.onExactTyped,
onFocus = { actions.onFocus(EditorFocus.Exact) },
)
}
SplitMode.FULL -> FullSplitPicker(me, partner, form.fullOn ?: me.id, actions.onFullOn, state.shares)
}
}
}
NotesField(form.notes, actions.onNotes, form.notesOpen, actions.onOpenNotes)
ScheduleSection(state, actions)
if (!state.isNew) {
KvittButton(stringResource(DesignR.string.common_delete), actions.onAskDelete, kind = ButtonKind.DangerText, icon = KvittIcon.Delete, enabled = state.online && !state.busy)
}
}
}
/** "Schema" (design-spec S-19): day and first month (1 : 1.5), last month, hints, the paused switch. */
@Composable
private fun ScheduleSection(state: RecurringEditorState, actions: RecurringEditorActions) {
val form = state.form
val language = kvittLanguage()
Column(
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceContainerLow, KvittTheme.shapes.card).padding(KvittTheme.spacing.s4),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
Text(stringResource(R.string.settings_recurring_schedule), style = MaterialTheme.typography.titleSmall)
Row(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3), verticalAlignment = Alignment.Top) {
KvittTextField(
value = form.day,
onValueChange = actions.onDay,
label = stringResource(R.string.settings_recurring_day_of_month),
modifier = Modifier.weight(1f).onFocusChanged { if (it.isFocused) actions.onFocus(EditorFocus.None) },
error = if (state.errors.day && form.dayOfMonth != null) stringResource(DesignR.string.error_validation) else null,
keyboardType = KeyboardType.Number,
enabled = state.online,
)
PickerField(
stringResource(R.string.settings_recurring_first_month),
DateFormatter.monthYear(state.startPeriod, language),
error = state.errors.start?.let { stringResource(if (it == StartError.TooEarly) R.string.settings_recurring_error_start_too_early else DesignR.string.error_validation) },
enabled = state.online,
onClick = { actions.onOpenMonth(MonthField.First) },
modifier = Modifier.weight(WIDE_WEIGHT),
)
}
PickerField(
stringResource(R.string.settings_recurring_last_month),
form.end?.let { DateFormatter.monthYear(it, language) } ?: stringResource(R.string.settings_recurring_no_end),
error = if (state.errors.end) stringResource(R.string.settings_recurring_error_end_before_start) else null,
enabled = state.online,
onClick = { actions.onOpenMonth(MonthField.Last) },
)
if (state.dayClampHint) HintText(stringResource(R.string.settings_recurring_day_clamp_hint), Modifier.padding(horizontal = KvittTheme.spacing.s2))
if (!state.isNew) HintText(stringResource(R.string.settings_recurring_edit_hint), Modifier.padding(horizontal = KvittTheme.spacing.s2))
if (state.resumeHint) HintText(stringResource(R.string.settings_recurring_resume_hint), Modifier.padding(horizontal = KvittTheme.spacing.s2))
Row(Modifier.fillMaxWidth().padding(start = KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.settings_recurring_paused_switch), style = MaterialTheme.typography.titleMedium)
HintText(stringResource(R.string.settings_recurring_created_at, dayOrdinal(form.dayOfMonth ?: 1)))
}
Switch(form.paused, actions.onPaused, enabled = state.online)
}
}
}
/** First month : day = 1.5 : 1 (design-spec S-19). */
private const val WIDE_WEIGHT = 1.5f
/** A filled field that opens a picker: label over value, `expand_more` (the month fields of S-19). */
@Composable
private fun PickerField(label: String, value: String, error: String?, enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
val spoken = listOfNotNull(label, value, error).joinToString(stringResource(DesignR.string.common_separator_comma))
Column(modifier) {
Row(
Modifier
.fillMaxWidth()
.clip(KvittTheme.shapes.field)
.background(scheme.surfaceContainerHighest)
.clickable(enabled = enabled, onClick = onClick)
.clearAndSetSemantics { contentDescription = spoken; role = Role.Button }
.heightIn(min = KvittTheme.spacing.rowMin)
.padding(horizontal = KvittTheme.spacing.s4, vertical = KvittTheme.spacing.s2),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.bodySmall, color = if (error != null) scheme.error else scheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyLarge, color = if (enabled) scheme.onSurface else scheme.onSurfaceVariant)
}
KvittIconImage(KvittIcon.ExpandMore, null, tint = scheme.onSurfaceVariant)
}
if (error != null) {
Row(Modifier.padding(start = KvittTheme.spacing.s4, top = KvittTheme.spacing.s1), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), verticalAlignment = Alignment.CenterVertically) {
KvittIconImage(KvittIcon.Error, null, filled = true, size = KvittTheme.spacing.s4, tint = scheme.error)
Text(error, style = MaterialTheme.typography.bodySmall, color = scheme.error)
}
}
}
}
@Composable
private fun EditorDialogs(state: RecurringEditorState, actions: RecurringEditorActions) {
when (val dialog = state.dialog) {
EditorDialog.Discard -> ConfirmDialog(
icon = KvittIcon.Close,
title = stringResource(DesignR.string.common_discard_title),
body = "",
confirm = stringResource(DesignR.string.common_discard),
dismiss = stringResource(DesignR.string.common_keep_editing),
onConfirm = actions.onDiscard,
onDismiss = actions.onDismissDialog,
destructive = true,
)
EditorDialog.Delete -> ConfirmDialog(
icon = KvittIcon.Delete,
title = stringResource(R.string.settings_recurring_delete_title, state.form.description.trim()),
body = stringResource(R.string.settings_recurring_delete_body),
confirm = stringResource(DesignR.string.common_delete),
dismiss = stringResource(DesignR.string.common_cancel),
onConfirm = actions.onDelete,
onDismiss = actions.onDismissDialog,
destructive = true,
)
is EditorDialog.Month -> {
// The dialog window has its own configuration; the screen's language is passed in.
val language = kvittLanguage()
Dialog(actions.onDismissDialog) { MonthPickerContent(state, dialog.which, language, actions) }
}
null -> Unit
}
}
/**
* The month picker (design-spec S-19): titled with the field label, a year row and 12 month chips; months before the
* allowed start (AC-9.8: 12 months back; for the last month: before the first month) are disabled. The last month
* also offers "Ingen slutmånad".
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MonthPickerContent(state: RecurringEditorState, which: MonthField, language: KvittLanguage, actions: RecurringEditorActions) {
val minimum = if (which == MonthField.First) state.earliestStart else state.startPeriod
val selected = if (which == MonthField.First) state.startPeriod else state.form.end
val shownYear = remember(which) { mutableIntStateOf(Periods.year(selected ?: state.startPeriod)) }
val firstYear = Periods.year(minimum)
val years = (firstYear..Periods.year(state.options.today) + YEARS_AHEAD).toList()
Surface(shape = KvittTheme.shapes.card, color = MaterialTheme.colorScheme.surfaceContainerHigh, shadowElevation = KvittElevation.overlay) {
Column(Modifier.padding(KvittTheme.spacing.s6), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3)) {
Text(stringResource(if (which == MonthField.First) R.string.settings_recurring_first_month else R.string.settings_recurring_last_month), style = MaterialTheme.typography.headlineSmall)
FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
years.forEach { year ->
FilterChip(selected = year == shownYear.intValue, onClick = { shownYear.intValue = year }, label = { Text(year.toString()) })
}
}
FlowRow(horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2), maxItemsInEachRow = MONTH_COLUMNS) {
(1..12).forEach { month ->
val period = Periods.of(shownYear.intValue, month)
val name = DateFormatter.monthYear(period, language)
FilterChip(
selected = period == selected,
onClick = { actions.onMonth(which, period) },
enabled = period >= minimum,
label = { Text(DateFormatter.axisMonth(month, language)) },
modifier = Modifier.weight(1f).clearAndSetSemantics { contentDescription = name; role = Role.RadioButton },
)
}
}
if (which == MonthField.Last) {
FilterChip(selected = state.form.end == null, onClick = { actions.onMonth(which, null) }, label = { Text(stringResource(R.string.settings_recurring_no_end)) })
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
KvittButton(stringResource(DesignR.string.common_cancel), actions.onDismissDialog, kind = ButtonKind.Text)
}
}
}
}
private const val YEARS_AHEAD = 3
private const val MONTH_COLUMNS = 4
@Composable
private fun descriptionError(error: DescriptionError): String = when (error) {
DescriptionError.Required -> stringResource(DesignR.string.error_description_required)
DescriptionError.TooLong -> pluralStringResource(DesignR.plurals.error_too_long, DESCRIPTION_MAX, DESCRIPTION_MAX)
DescriptionError.Refused -> stringResource(DesignR.string.error_validation)
}
@@ -0,0 +1,279 @@
package se.hagstedt.kvitt.feature.settings.recurring
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
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.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.feature.settings.common.UiMessage
import se.hagstedt.kvitt.feature.settings.common.failureCode
import se.hagstedt.kvitt.feature.settings.common.failureMessage
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.AccountSummary
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ExpenseEditorOptions
import se.hagstedt.kvitt.model.ExpenseRepository
import se.hagstedt.kvitt.model.HomeRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.Share
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UserId
import javax.inject.Inject
/** The S-19 editor (design-spec §4 S-19): the form with everything the screen needs resolved. */
data class RecurringEditorState(
val options: ExpenseEditorOptions,
val original: RecurringTemplate?,
val form: RecurringForm,
val account: Account?,
val accountColorIndex: Int,
val choices: List<AccountChoice>,
val category: Category?,
val quickRow: List<Category>,
val shares: List<Share>?,
val errors: RecurringErrors,
val valid: Boolean,
val online: Boolean,
val busy: Boolean,
val sheet: EditorSheet?,
val dialog: EditorDialog?,
val message: UiMessage?,
val done: Boolean,
) {
val isNew: Boolean get() = original == null
val startPeriod: String get() = form.startPeriod(options.today)
val earliestStart: String get() = Periods.earliestStart(options.today)
val changed: Boolean get() = original == null || form.template(original, options.me.id, options.partner.id, options.today).copy(nextOccurrence = original.nextOccurrence) != original.normalised(options.today)
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 dayClampHint: Boolean get() = (form.dayOfMonth ?: 0) >= 29
val resumeHint: Boolean get() = original?.paused == true && !form.paused
}
/** The template as the form would rebuild it (draft date normalised), to tell whether anything changed. */
private fun RecurringTemplate.normalised(today: String) = copy(draft = draft.copy(date = today, description = draft.description.trim(), notes = draft.notes.trim()))
enum class EditorSheet { Category, Account }
sealed interface EditorDialog {
data object Discard : EditorDialog
data object Delete : EditorDialog
data class Month(val which: MonthField) : EditorDialog
}
enum class MonthField { First, Last }
@HiltViewModel
class RecurringEditorViewModel @Inject constructor(
savedState: SavedStateHandle,
private val expenses: ExpenseRepository,
home: HomeRepository,
private val recurring: RecurringRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
/** Navigation argument: the template to edit, absent for a new one. */
private val id: TemplateId? = savedState.get<String>(ARG_TEMPLATE_ID)?.let { TemplateId(it) }
private val local = MutableStateFlow(Local())
private val sources = combine(expenses.editorOptions(), home.home(), recurring.all(), connectivity.online) { o, h, t, online -> Sources(o, h.active + h.archived, t, online) }
val state: StateFlow<RecurringEditorState?> = combine(sources, local) { src, l ->
val original = id?.let { i -> src.templates.firstOrNull { it.id == i } }
if (id != null && original == null) return@combine null
val options = src.options
val form = l.form ?: if (original != null) RecurringForm.of(original, options.me.id) else RecurringForm.new(options)
val full = validate(form, options.today, original)
val ordered = src.summaries.map { it.account }
val account = ordered.firstOrNull { it.id == form.accountId }
val category = options.categories.firstOrNull { it.id == form.categoryId }
val selected = form.categoryId
val frequent = form.accountId?.let { options.frequentCategoryIds[it] }.orEmpty()
val draft = form.draft(options.me.id, options.partner.id, options.today)
RecurringEditorState(
options = options,
original = original,
form = form,
account = account,
accountColorIndex = ordered.indexOfFirst { it.id == form.accountId }.coerceAtLeast(0),
choices = src.summaries.mapIndexed { index, s -> AccountChoice(s, index) }.filter { !it.summary.account.archived },
category = category,
quickRow = quickRowCategories(options.categories, frequent, selected),
shares = if (draft.amountOre != null && draft.accountId != null) expenses.preview(draft) else null,
errors = l.serverErrors ?: if (l.triedSave) full else full.copy(amount = null, description = null, day = false),
valid = !full.any,
online = src.online,
busy = l.busy,
sheet = l.sheet,
dialog = l.dialog,
message = l.message,
done = l.done,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
private fun edit(change: (RecurringForm) -> RecurringForm) = local.update { l ->
val base = l.form ?: state.value?.form ?: return@update l
l.copy(form = change(base), serverErrors = null)
}
fun onKey(key: AmountKey) = edit { it.press(key) }
fun focus(focus: EditorFocus) = edit { it.copy(focus = focus) }
fun onDescription(text: String) = edit { it.copy(description = text) }
fun onNotes(text: String) = edit { it.copy(notes = text) }
fun openNotes() = edit { it.copy(notesOpen = true, focus = EditorFocus.None) }
/** A category picked in the quick row or the sheet ("picked by hand", CS-2). */
fun onCategory(id: CategoryId) {
edit { it.copy(categoryId = id, categoryPicked = true) }
local.update { it.copy(sheet = null) }
}
/** A new account; the AC-8.6 default category follows unless one was picked by hand. */
fun onAccount(id: AccountId) {
val options = state.value?.options ?: return
edit { f -> f.copy(accountId = id, categoryId = if (f.categoryPicked) f.categoryId else options.defaultCategoryFor[id] ?: f.categoryId) }
local.update { it.copy(sheet = null) }
}
/** AC-8.3: one tap swaps the payer. */
fun togglePayer() {
val s = state.value ?: return
edit { f -> f.copy(payer = if ((f.payer ?: s.options.me.id) == s.options.me.id) s.options.partner.id else s.options.me.id) }
}
fun toggleSplit() = edit { it.copy(splitOpen = !it.splitOpen, focus = EditorFocus.None) }
fun onMode(mode: SplitMode) = edit { it.copy(mode = mode, focus = if (mode == SplitMode.EXACT) EditorFocus.Exact else EditorFocus.None) }
/** Slider and presets set the viewer's percent. */
fun onViewerPercent(percent: Int) = edit { it.copy(ratioTyped = RatioField.Viewer, ratioInput = AmountInput(percent.coerceIn(0, 100).toString())) }
fun onRatioFocus(field: RatioField) = focus(if (field == RatioField.Viewer) EditorFocus.RatioViewer else EditorFocus.RatioPartner)
/** AC-8.7: tapping the derived field makes that person the typed one. */
fun onExactTyped(user: UserId) = edit { f ->
val derived = (f.amount.ore ?: 0L) - (f.exactInput.ore ?: 0L)
f.copy(exactTyped = user, exactInput = AmountInput.of(derived.coerceAtLeast(0L)), focus = EditorFocus.Exact)
}
fun onFullOn(user: UserId) = edit { it.copy(fullOn = user) }
fun onDay(text: String) = edit { it.withDay(text) }
fun onPaused(paused: Boolean) = edit { it.copy(paused = paused) }
fun openSheet(sheet: EditorSheet) {
focus(EditorFocus.None)
local.update { it.copy(sheet = sheet) }
}
fun closeSheet() = local.update { it.copy(sheet = null) }
fun openMonth(which: MonthField) {
focus(EditorFocus.None)
local.update { it.copy(dialog = EditorDialog.Month(which)) }
}
/** A month from the picker; null on the last month means "Ingen slutmånad". */
fun onMonth(which: MonthField, period: String?) {
edit { f -> if (which == MonthField.First) f.copy(start = period ?: f.start) else f.copy(end = period) }
local.update { it.copy(dialog = null) }
}
fun dismissDialog() = local.update { it.copy(dialog = null) }
/** Close: with changes the discard dialog first (design-spec S-19). */
fun close() {
val s = state.value
if (s != null && s.changed && !(s.isNew && s.form == RecurringForm.new(s.options))) {
local.update { it.copy(dialog = EditorDialog.Discard) }
} else {
local.update { it.copy(done = true) }
}
}
fun discard() = local.update { it.copy(dialog = null, done = true) }
fun askDelete() = local.update { it.copy(dialog = EditorDialog.Delete) }
/** "Spara" (online): `create` / `update`; success → back; a refused field is marked inline, network → snackbar. */
fun save() {
local.update { it.copy(triedSave = true) }
val s = state.value ?: return
if (!s.canSave) return
val template = s.form.template(s.original, s.options.me.id, s.options.partner.id, s.options.today)
focus(EditorFocus.None)
local.update { it.copy(busy = true) }
viewModelScope.launch {
val result: OnlineResult<*> = if (s.original == null) recurring.create(template) else recurring.update(template)
local.update { it.copy(busy = false) }
when {
result is OnlineResult.Ok -> local.update { it.copy(done = true) }
result is OnlineResult.Failed && result.code == "validation_failed" && serverErrors(result.field) != null ->
local.update { it.copy(serverErrors = serverErrors(result.field)) }
else -> fail(result, retry = true)
}
}
}
/** Confirmed "Ta bort": created expenses stay (design-spec S-19). */
fun delete() {
val original = state.value?.original ?: return
local.update { it.copy(dialog = null, busy = true) }
viewModelScope.launch {
val result = recurring.delete(original.id)
local.update { it.copy(busy = false) }
if (result is OnlineResult.Ok) local.update { it.copy(done = true) } else fail(result, retry = false)
}
}
fun onRetry() = save()
fun onMessageShown() = local.update { it.copy(message = null) }
private fun fail(result: OnlineResult<*>, retry: Boolean) {
val code = result.failureCode() ?: return
local.update { it.copy(message = failureMessage(code, canRetry = retry)) }
}
private data class Sources(val options: ExpenseEditorOptions, val summaries: List<AccountSummary>, val templates: List<RecurringTemplate>, val online: Boolean)
private data class Local(
val form: RecurringForm? = null,
val triedSave: Boolean = false,
val serverErrors: RecurringErrors? = null,
val busy: Boolean = false,
val sheet: EditorSheet? = null,
val dialog: EditorDialog? = null,
val message: UiMessage? = null,
val done: Boolean = false,
)
companion object {
/** The `SavedStateHandle` key of the template id (a string; absent for a new template). */
const val ARG_TEMPLATE_ID = "templateId"
}
}
@@ -0,0 +1,204 @@
package se.hagstedt.kvitt.feature.settings.recurring
import se.hagstedt.kvitt.design.component.RatioField
import se.hagstedt.kvitt.design.format.AmountInput
import se.hagstedt.kvitt.design.format.AmountKey
import se.hagstedt.kvitt.model.AccountId
import se.hagstedt.kvitt.model.CategoryId
import se.hagstedt.kvitt.model.ExpenseDraft
import se.hagstedt.kvitt.model.ExpenseEditorOptions
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.SplitMode
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.model.UserId
/** Which keypad field has focus (design-spec §3.7: the keypad drives the amount, the exact share and the percents). */
enum class EditorFocus { None, Amount, RatioViewer, RatioPartner, Exact }
/** §9.6: description 1 to 100, notes 0 to 1000 code points. */
const val DESCRIPTION_MAX = 100
const val BP_TOTAL = 10_000
/**
* The S-19 editor's input, independent of the repositories: the expense fields of S-7 without the date (D-019) plus
* the schedule of FR-9. Pure, so the editor's rules are unit-tested without Android.
*
* Ratio mode keeps the typed percent field ([ratioTyped], [ratioInput] in hundredths of a percent = basis points);
* the other field is 100 minus it. Exact mode keeps the typed person's amount ([exactTyped], [exactInput]).
* [start] null means the AC-9.8 default for the current [day].
*/
data class RecurringForm(
val amount: AmountInput = AmountInput(),
val description: String = "",
val accountId: AccountId? = null,
val categoryId: CategoryId? = null,
val categoryPicked: Boolean = false,
val payer: UserId? = null,
val mode: SplitMode = SplitMode.DEFAULT,
val ratioTyped: RatioField = RatioField.Viewer,
val ratioInput: AmountInput = AmountInput("50"),
val exactTyped: UserId? = null,
val exactInput: AmountInput = AmountInput(),
val fullOn: UserId? = null,
val notes: String = "",
val notesOpen: Boolean = false,
val day: String = "",
val start: String? = null,
val end: String? = null,
val paused: Boolean = false,
val focus: EditorFocus = EditorFocus.None,
val splitOpen: Boolean = false,
) {
val dayOfMonth: Int? get() = day.toIntOrNull()?.takeIf { it in 1..MAX_DAY }
fun startPeriod(today: String): String = start ?: Periods.defaultStart(today, dayOfMonth)
/** The viewer's basis points in ratio mode. */
val viewerBp: Int get() {
val typed = (ratioInput.ore ?: 0L).toInt().coerceIn(0, BP_TOTAL)
return if (ratioTyped == RatioField.Viewer) typed else BP_TOTAL - typed
}
/** The draft the server and `ExpenseRepository.preview` see; the date is today (a template has none). */
fun draft(me: UserId, partner: UserId, today: String): ExpenseDraft {
val payerId = payer ?: me
val typed = exactTyped ?: me
val other = if (typed == me) partner else me
return ExpenseDraft(
id = null,
accountId = accountId,
categoryId = categoryId,
amountOre = amount.ore,
description = description.trim(),
notes = notes.trim(),
date = today,
payer = payerId,
mode = mode,
ratioBp = if (mode == SplitMode.RATIO) mapOf(me to viewerBp, partner to BP_TOTAL - viewerBp) else null,
exactOre = if (mode == SplitMode.EXACT) exactInput.ore?.let { typedOre -> mapOf(typed to typedOre, other to (amount.ore ?: 0L) - typedOre) } else null,
fullOn = if (mode == SplitMode.FULL) fullOn ?: me else null,
)
}
fun template(original: RecurringTemplate?, me: UserId, partner: UserId, today: String): RecurringTemplate = RecurringTemplate(
id = original?.id ?: TemplateId(""),
draft = draft(me, partner, today),
dayOfMonth = dayOfMonth ?: 0,
startPeriod = startPeriod(today),
endPeriod = end,
paused = paused,
nextOccurrence = original?.nextOccurrence,
)
/** One keypad key on the focused field; a rejected key leaves the form unchanged. */
fun press(key: AmountKey): RecurringForm = when (focus) {
EditorFocus.Amount -> amount.press(key)?.let { copy(amount = it) } ?: this
EditorFocus.RatioViewer, EditorFocus.RatioPartner -> {
val field = if (focus == EditorFocus.RatioViewer) RatioField.Viewer else RatioField.Partner
val base = if (ratioTyped == field) ratioInput else AmountInput()
base.press(key, AmountInput.MAX_PERCENT_HUNDREDTHS)?.let { copy(ratioTyped = field, ratioInput = it) } ?: this
}
EditorFocus.Exact -> exactInput.press(key)?.let { copy(exactInput = it) } ?: this
EditorFocus.None -> this
}
/** Day field input: digits only, at most two, at most 31. */
fun withDay(text: String): RecurringForm {
val digits = text.filter { it.isDigit() }.take(2)
if (digits.isNotEmpty() && digits.toInt() > MAX_DAY) return this
return copy(day = digits)
}
companion object {
const val MAX_DAY = 31
/** A new template: AC-8.6-style defaults (no category suggestion in S-19, D-059 / design-spec §6 point 3). */
fun new(options: ExpenseEditorOptions): RecurringForm {
val account = options.defaultAccountId
return RecurringForm(
accountId = account,
categoryId = account?.let { options.defaultCategoryFor[it] },
payer = options.me.id,
exactTyped = options.me.id,
fullOn = options.me.id,
focus = EditorFocus.Amount,
day = options.today.substring(8, 10).trimStart('0'),
)
}
/** An existing template opened for editing. */
fun of(t: RecurringTemplate, me: UserId): RecurringForm {
val d = t.draft
val viewerBp = d.ratioBp?.get(me)
val typed = d.exactOre?.keys?.firstOrNull { it == me } ?: d.exactOre?.keys?.firstOrNull() ?: me
return RecurringForm(
amount = d.amountOre?.let { AmountInput.of(it) } ?: AmountInput(),
description = d.description,
accountId = d.accountId,
categoryId = d.categoryId,
categoryPicked = true,
payer = d.payer,
mode = d.mode,
ratioInput = AmountInput.of((viewerBp ?: BP_TOTAL / 2).toLong()),
exactTyped = typed,
exactInput = d.exactOre?.get(typed)?.let { AmountInput.of(it) } ?: AmountInput(),
fullOn = d.fullOn ?: me,
notes = d.notes,
notesOpen = d.notes.isNotEmpty(),
day = t.dayOfMonth.toString(),
start = t.startPeriod,
end = t.endPeriod,
paused = t.paused,
)
}
}
}
/** Field errors of the editor, local or from a refused save (`validation_failed` with a field, §14.3.2). */
data class RecurringErrors(
val amount: AmountError? = null,
val description: DescriptionError? = null,
val day: Boolean = false,
val start: StartError? = null,
val end: Boolean = false,
val split: Boolean = false,
) {
val any: Boolean get() = amount != null || description != null || day || start != null || end || split
}
enum class AmountError { Required, Refused }
enum class DescriptionError { Required, TooLong, Refused }
enum class StartError { TooEarly, Refused }
/** The local checks of §9.6 and AC-9.8. [original] set: an unchanged first month is never "too early". */
fun validate(form: RecurringForm, today: String, original: RecurringTemplate?): RecurringErrors {
val description = form.description.trim()
val length = description.codePointCount(0, description.length)
val start = form.startPeriod(today)
val exactOver = form.mode == SplitMode.EXACT && (form.exactInput.ore ?: 0L) > (form.amount.ore ?: 0L)
return RecurringErrors(
amount = if (form.amount.ore == null || form.amount.ore == 0L) AmountError.Required else null,
description = when {
length == 0 -> DescriptionError.Required
length > DESCRIPTION_MAX -> DescriptionError.TooLong
else -> null
},
day = form.dayOfMonth == null,
start = if (start != original?.startPeriod && start < Periods.earliestStart(today)) StartError.TooEarly else null,
end = form.end?.let { it < start } == true,
split = exactOver || (form.mode == SplitMode.EXACT && form.exactInput.ore == null),
)
}
/** The error a refused save shows at its field (`validation_failed`, field per §14.3.2); null: no field to mark. */
fun serverErrors(field: String?): RecurringErrors? = when (field) {
"amount_ore", "amountOre" -> RecurringErrors(amount = AmountError.Refused)
"description" -> RecurringErrors(description = DescriptionError.Refused)
"day_of_month" -> RecurringErrors(day = true)
"start_period" -> RecurringErrors(start = StartError.Refused)
"end_period" -> RecurringErrors(end = true)
"splits", "split" -> RecurringErrors(split = true)
else -> null
}
@@ -0,0 +1,174 @@
package se.hagstedt.kvitt.feature.settings.recurring
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.style.TextOverflow
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AddExpenseFab
import se.hagstedt.kvitt.design.component.AmountStyle
import se.hagstedt.kvitt.design.component.AmountTone
import se.hagstedt.kvitt.design.component.BadgeKind
import se.hagstedt.kvitt.design.component.CategoryTile
import se.hagstedt.kvitt.design.component.EmptyState
import se.hagstedt.kvitt.design.component.KvittBadge
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.LocalKvittHousehold
import se.hagstedt.kvitt.design.component.MoneyText
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.SegmentedItemScope
import se.hagstedt.kvitt.design.component.TopBarNavigation
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.component.listDate
import se.hagstedt.kvitt.design.component.spokenMoney
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.feature.settings.R
import se.hagstedt.kvitt.feature.settings.common.SettingsScaffold
import se.hagstedt.kvitt.feature.settings.common.OfflineBanner
import se.hagstedt.kvitt.model.TemplateId
import se.hagstedt.kvitt.core.design.R as DesignR
/** S-19 Recurring expenses list. */
@Composable
fun RecurringListScreen(
onBack: () -> Unit,
onOpenTemplate: (TemplateId) -> Unit,
onNewTemplate: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RecurringListViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
RecurringListContent(state, RecurringListActions(onBack, onOpenTemplate, onNewTemplate), modifier)
}
class RecurringListActions(val onBack: () -> Unit = {}, val onOpen: (TemplateId) -> Unit = {}, val onNew: () -> Unit = {})
/** Stateless S-19 list. */
@Composable
fun RecurringListContent(state: RecurringListState?, actions: RecurringListActions, modifier: Modifier = Modifier) {
val empty = state != null && state.rows.isEmpty()
SettingsScaffold(
modifier.fillMaxSize(),
topBar = { KvittTopAppBar(stringResource(R.string.settings_recurring_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
floatingActionButton = {
if (state != null && !empty) AddExpenseFab(actions.onNew, text = stringResource(R.string.settings_recurring_new), enabled = state.online)
},
) { padding ->
if (state == null) return@SettingsScaffold
ProvideHousehold(state.me, state.partner) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter, vertical = KvittTheme.spacing.s2),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.detailGap),
) {
if (!state.online) OfflineBanner(stringResource(R.string.settings_recurring_offline_banner))
if (empty) {
EmptyState(
stringResource(R.string.settings_recurring_empty_title),
stringResource(R.string.settings_recurring_empty_body),
action = if (state.online) stringResource(R.string.settings_recurring_empty_action) else null,
actionIcon = KvittIcon.Add,
onAction = actions.onNew,
)
} else {
SegmentedGroup(state.rows.size) { item ->
val row = state.rows[item.index]
TemplateRow(item, row, state.online) { actions.onOpen(row.template.id) }
}
Spacer(Modifier.height(KvittTheme.spacing.fabClearance))
}
}
}
}
}
/**
* One template (design-spec S-19): tile 40 (muted when paused), description over "Huset · Anna betalar" over the
* schedule lines or the "Pausad" badge, amount right (70 % when paused). One TalkBack node.
*/
@Composable
private fun TemplateRow(item: SegmentedItemScope, row: RecurringRow, online: Boolean, onClick: () -> Unit) {
val t = row.template
val paused = t.paused
val payer = row.payer
val accountName = row.account?.name.orEmpty()
val who = when {
payer == null -> accountName
LocalKvittHousehold.current.isViewer(payer.id) -> stringResource(R.string.settings_recurring_row_you_pay, accountName)
else -> stringResource(R.string.settings_recurring_row_partner_pays, accountName, payer.displayName)
}
val every = stringResource(R.string.settings_recurring_every_month_on, dayOrdinal(t.dayOfMonth))
val next = t.nextOccurrence?.let { stringResource(R.string.settings_recurring_next, listDate(it)) }
val pausedWord = stringResource(R.string.settings_recurring_paused)
val comma = stringResource(DesignR.string.common_separator_comma)
val spokenNext = t.nextOccurrence?.let {
stringResource(R.string.settings_recurring_next, DateFormatter.spokenDate(DateFormatter.parseDate(it), LocalKvittClock.current.today, kvittLanguage()))
}
val spoken = listOfNotNull(t.draft.description, spokenMoney(t.draft.amountOre ?: 0L), who, every, if (paused) pausedWord else spokenNext).joinToString(comma)
Row(
Modifier
.fillMaxWidth()
.clip(item.shape)
.background(item.fill)
.clickable(enabled = online, onClick = onClick)
.clearAndSetSemantics { contentDescription = spoken; if (online) role = Role.Button }
.heightIn(min = KvittTheme.spacing.ledgerRowMin)
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittDimens.dialogGap),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
row.category?.let { CategoryTile(it, KvittDimens.tileLedger, archived = paused || it.archived) } ?: Spacer(Modifier.width(KvittDimens.tileLedger))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
Text(t.draft.description, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(who, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (paused) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2)) {
KvittBadge(pausedWord, kind = BadgeKind.Lantern, icon = KvittIcon.Pause)
Text(every, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
Text(every, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
if (next != null) Text(next, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
MoneyText(t.draft.amountOre ?: 0L, AmountStyle.Small, AmountTone.Neutral, Modifier.alpha(if (paused) PAUSED_ALPHA else 1f))
}
}
/** Design-spec S-19: a paused template's amount at 70 % opacity. */
private const val PAUSED_ALPHA = 0.7f
@@ -0,0 +1,53 @@
package se.hagstedt.kvitt.feature.settings.recurring
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import se.hagstedt.kvitt.model.Account
import se.hagstedt.kvitt.model.AccountRepository
import se.hagstedt.kvitt.model.Category
import se.hagstedt.kvitt.model.CategoryRepository
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.ProfileRepository
import se.hagstedt.kvitt.model.RecurringRepository
import se.hagstedt.kvitt.model.RecurringTemplate
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/** One S-19 row (AC-9.7): the template with its account, category and payer resolved. */
data class RecurringRow(val template: RecurringTemplate, val account: Account?, val category: Category?, val payer: User?)
/** S-19 list (design-spec §4 S-19). */
data class RecurringListState(val me: User, val partner: User?, val rows: List<RecurringRow>, val online: Boolean)
@HiltViewModel
class RecurringListViewModel @Inject constructor(
recurring: RecurringRepository,
accounts: AccountRepository,
categories: CategoryRepository,
session: SessionRepository,
profile: ProfileRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val people = combine(session.session, profile.household()) { s, h -> s to h }
val state: StateFlow<RecurringListState?> = combine(recurring.all(), accounts.all(), categories.all(), people, connectivity.online) { templates, acc, cats, (s, household), online ->
val paired = s as? SessionState.Paired ?: return@combine null
val me = household.firstOrNull { it.id == paired.me.id } ?: paired.me
val users = household.ifEmpty { listOfNotNull(paired.me, paired.partner) }
RecurringListState(
me = me,
partner = household.firstOrNull { it.id != me.id } ?: paired.partner,
rows = templates.map { t ->
RecurringRow(t, acc.firstOrNull { it.id == t.draft.accountId }, cats.firstOrNull { it.id == t.draft.categoryId }, users.firstOrNull { it.id == t.draft.payer })
},
online = online,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
}
@@ -0,0 +1,12 @@
package se.hagstedt.kvitt.feature.settings.recurring
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import se.hagstedt.kvitt.feature.settings.R
/** Days whose Swedish ordinal ends in ":a" (1:a, 2:a, 21:a, 22:a, 31:a); every other day takes ":e". */
fun ordinalTakesA(day: Int): Boolean = day % 10 in 1..2 && day % 100 !in 11..12
/** "25:e" / "1:a" in Swedish, "25" in English (`settings_recurring_day_ordinal_*`). */
@Composable
fun dayOrdinal(day: Int): String = stringResource(if (ordinalTakesA(day)) R.string.settings_recurring_day_ordinal_a else R.string.settings_recurring_day_ordinal_e, day)
@@ -0,0 +1,194 @@
<?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. -->
<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>
<!-- S-13 own row when the viewer is the admin -->
<string name="settings_role_admin">Du · Administratör</string>
<!-- S-13 own row when the viewer is the member -->
<string name="settings_role_member">Du</string>
<!-- S-13 the admin's row seen by the member; the member's row seen by the admin is common_partner -->
<string name="settings_role_admin_other">Admin</string>
<string name="settings_profile">Profil</string>
<!-- S-13 row subtitle -->
<string name="settings_profile_summary">Namn och bild</string>
<!-- S-14; %1$s partner -->
<string name="settings_profile_hint">Så här ser %1$s dig i Kvitt.</string>
<string name="settings_categories">Kategorier</string>
<!-- plural one; S-13 row subtitle -->
<plurals name="settings_categories_summary">
<item quantity="one">%d aktiv</item>
<item quantity="other">%d aktiva</item>
</plurals>
<string name="settings_recurring">Återkommande utgifter</string>
<!-- plural one; S-13 row subtitle, joined with common_separator_comma -->
<plurals name="settings_recurring_summary_active">
<item quantity="one">%d aktiv</item>
<item quantity="other">%d aktiva</item>
</plurals>
<!-- plural one; shown when > 0 -->
<plurals name="settings_recurring_summary_paused">
<item quantity="one">%d pausad</item>
<item quantity="other">%d pausade</item>
</plurals>
<string name="settings_devices">Enheter</string>
<!-- plural one; S-13 row subtitle -->
<plurals name="settings_devices_summary">
<item quantity="one">%d enhet</item>
<item quantity="other">%d enheter</item>
</plurals>
<string name="settings_pair_device">Para ihop en enhet</string>
<string name="settings_sync_status">Synkstatus</string>
<string name="settings_about">Om Kvitt</string>
<!-- S-13 row subtitle; version name -->
<string name="settings_about_summary">Version %1$s</string>
<string name="settings_pending_badge">%d väntar</string>
<string name="settings_update_badge">Uppdatering finns</string>
<string name="settings_categories_title">Kategorier</string>
<string name="settings_categories_new">Ny kategori</string>
<string name="settings_categories_archived">Arkiverade (%d)</string>
<string name="settings_categories_reorder_offline">Kräver anslutning för att ändra</string>
<!-- TalkBack custom action -->
<string name="settings_categories_move_up">Flytta upp</string>
<!-- TalkBack custom action -->
<string name="settings_categories_move_down">Flytta ned</string>
<!-- TalkBack of the drag handle -->
<string name="settings_categories_drag_handle">Flytta %1$s</string>
<string name="settings_category_editor_new">Ny kategori</string>
<string name="settings_category_editor_edit">Ändra kategori</string>
<!-- badge in the editor of an archived category -->
<string name="settings_category_archived_badge">Arkiverad</string>
<!-- AC-7.4 -->
<string name="settings_category_editor_reset_name">Återställ namn</string>
<!-- AC-7.4 -->
<string name="settings_category_editor_default_name">Standardnamn: %1$s</string>
<string name="settings_category_editor_icon">Ikon</string>
<string name="settings_category_editor_color">Färg</string>
<string name="settings_category_editor_archive">Arkivera</string>
<string name="settings_category_editor_unarchive">Återställ från arkiv</string>
<string name="settings_category_editor_delete">Ta bort kategori</string>
<!-- confirmation dialog; %1$s category name -->
<string name="settings_category_delete_title">Ta bort kategorin %1$s?</string>
<!-- confirmation dialog body -->
<string name="settings_category_delete_body">Den försvinner från listan för er båda. Det går inte att ångra.</string>
<!-- category_is_default -->
<string name="settings_category_delete_blocked_default">Standardkategorier kan inte tas bort, bara arkiveras</string>
<!-- inline reason under the disabled delete -->
<string name="settings_category_delete_blocked_default_short">Kan inte tas bort: standardkategori</string>
<!-- category_in_use -->
<string name="settings_category_delete_blocked_in_use">Kategorin används av utgifter eller återkommande utgifter. Arkivera den i stället.</string>
<!-- category_in_use, shown for an archived category -->
<string name="settings_category_delete_blocked_in_use_archived">Kategorin används av utgifter eller återkommande utgifter och kan inte tas bort.</string>
<string name="settings_about_up_to_date">Du har den senaste versionen</string>
<string name="settings_about_check_update">Sök efter uppdatering</string>
<string name="settings_recurring_title">Återkommande utgifter</string>
<!-- S-19 FAB -->
<string name="settings_recurring_new">Ny återkommande</string>
<!-- %1$s = settings_recurring_day_ordinal_a or _e -->
<string name="settings_recurring_every_month_on">Varje månad den %1$s</string>
<!-- sv ordinal for every day not covered by _a -->
<string name="settings_recurring_day_ordinal_e">%1$d:e</string>
<!-- sv ordinal for days 1, 2, 21, 22, 31 -->
<string name="settings_recurring_day_ordinal_a">%1$d:a</string>
<!-- %1$s list date -->
<string name="settings_recurring_next">Nästa %1$s</string>
<!-- list row; %1$s account name -->
<string name="settings_recurring_row_you_pay">%1$s · Du betalar</string>
<!-- list row; %1$s account, %2$s partner -->
<string name="settings_recurring_row_partner_pays">%1$s · %2$s betalar</string>
<string name="settings_recurring_paused">Pausad</string>
<string name="settings_recurring_empty_title">Inga återkommande utgifter</string>
<string name="settings_recurring_empty_body">Hyra, el och streaming kan läggas in automatiskt varje månad.</string>
<string name="settings_recurring_empty_action">Ny återkommande utgift</string>
<string name="settings_recurring_editor_new">Ny återkommande utgift</string>
<!-- S-19 editor title when editing -->
<string name="settings_recurring_editor_edit">Återkommande utgift</string>
<!-- editor section title -->
<string name="settings_recurring_schedule">Schema</string>
<string name="settings_recurring_day_of_month">Dag i månaden</string>
<!-- days 29–31 -->
<string name="settings_recurring_day_clamp_hint">I kortare månader används sista dagen</string>
<string name="settings_recurring_first_month">Första månad</string>
<string name="settings_recurring_last_month">Sista månad (valfritt)</string>
<!-- placeholder of the last-month field -->
<string name="settings_recurring_no_end">Ingen slutmånad</string>
<string name="settings_recurring_paused_switch">Pausad</string>
<!-- §9.5; %1$s = settings_recurring_day_ordinal_a or _e -->
<string name="settings_recurring_created_at">Skapas kl. 06:00 den %1$s varje månad</string>
<!-- §9.5 -->
<string name="settings_recurring_resume_hint">Månader som passerat under pausen skapas inte i efterhand.</string>
<!-- AC-9.4 -->
<string name="settings_recurring_edit_hint">Ändringen gäller kommande utgifter.</string>
<string name="settings_recurring_delete_title">Ta bort %1$s?</string>
<string name="settings_recurring_delete_body">Utgifter som redan skapats finns kvar.</string>
<string name="settings_recurring_offline_banner">Kräver anslutning. Återkommande utgifter kan bara ändras när telefonen är online.</string>
<string name="settings_recurring_error_end_before_start">Sista månaden kan inte vara före den första</string>
<string name="settings_recurring_error_start_too_early">Högst 12 månader bakåt</string>
<string name="settings_devices_title">Enheter</string>
<string name="settings_devices_paired">Parad %1$s</string>
<string name="settings_devices_last_seen">Senast sedd %1$s</string>
<string name="settings_devices_app_version">Kvitt %1$s</string>
<!-- AC-3.1 -->
<string name="settings_devices_this_device">Den här enheten</string>
<!-- row button and confirm button (AC-3.2) -->
<string name="settings_devices_revoke">Ta bort åtkomst</string>
<!-- AC-3.2 -->
<string name="settings_devices_cannot_revoke_current">Du kan inte ta bort åtkomsten för den telefon du använder just nu.</string>
<!-- %1$s device name -->
<string name="settings_devices_revoke_title">Ta bort åtkomst för %1$s?</string>
<!-- one of the viewer's own other devices -->
<string name="settings_devices_revoke_body_own">Telefonen loggas ut och allt Kvitt-data på den raderas. Du kan para ihop den igen med en ny inbjudningskod.</string>
<!-- admin revoking the partner's device; sv genitive as onboarding_profile_joining -->
<string name="settings_devices_revoke_body_partner">%1$ss telefon loggas ut och allt Kvitt-data på den raderas. %1$s kan para ihop den igen med en ny inbjudningskod.</string>
<string name="settings_devices_offline">Kräver anslutning för att visa enheter</string>
<string name="settings_devices_loading">Hämtar enheter…</string>
<string name="settings_invites_title_device">Para ihop en enhet</string>
<!-- created invite card title -->
<string name="settings_invites_new_device_for">Ny enhet för %1$s</string>
<!-- created partner invite card title -->
<string name="settings_invites_new_partner">Inbjudan till din partner</string>
<!-- S-21 create card -->
<string name="settings_invites_create_body">Skapa en engångskod för att para ihop en telefon till. Koden gäller i 72 timmar.</string>
<string name="settings_invites_for_me">För mig</string>
<string name="settings_invites_for_partner">För %1$s</string>
<string name="settings_invites_valid_until">Gäller till %1$s</string>
<string name="settings_invites_share">Dela</string>
<string name="settings_invites_copy_code">Kopiera kod</string>
<string name="settings_invites_code_copied">Koden kopierades</string>
<string name="settings_invites_instructions">Öppna länken på den nya telefonen, eller skriv in koden i Kvitt. Koden fungerar en gång.</string>
<string name="settings_invites_share_message">Gå med i vårt hushåll i Kvitt: %1$s Koden är %2$s.</string>
<string name="settings_invites_pending">Väntande inbjudningar</string>
<string name="settings_invites_none">Inga väntande inbjudningar</string>
<!-- §9.9 -->
<string name="settings_invites_offline_banner">Kräver anslutning. Inbjudningar kan bara skapas och visas när telefonen är online.</string>
<string name="settings_invites_kind_partner">Partner</string>
<!-- pending invite row; %1$s the user the device is for -->
<string name="settings_invites_kind_device">Enhet · %1$s</string>
<!-- %1$s time_today_at or list date and time -->
<string name="settings_invites_created">Skapad %1$s</string>
<string name="settings_invites_code_hidden">Koden visas bara när den skapas</string>
<!-- TalkBack of the Avbryt (common_cancel) button on a pending invite row -->
<string name="settings_invites_cancel">Avbryt inbjudan</string>
<string name="settings_invites_cancel_title">Avbryta inbjudan?</string>
<string name="settings_about_title">Om Kvitt</string>
<!-- version name and code -->
<string name="settings_about_version">Kvitt %1$s (%2$d)</string>
<string name="settings_about_server">Server</string>
<!-- label; value from /health -->
<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>
@@ -0,0 +1,194 @@
<?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. -->
<resources>
<string name="settings_profile_offline_banner">Needs connection. Your profile can only be changed online.</string>
<string name="settings_title">Settings</string>
<!-- S-13 own row when the viewer is the admin -->
<string name="settings_role_admin">You · Admin</string>
<!-- S-13 own row when the viewer is the member -->
<string name="settings_role_member">You</string>
<!-- S-13 the admin's row seen by the member; the member's row seen by the admin is common_partner -->
<string name="settings_role_admin_other">Admin</string>
<string name="settings_profile">Profile</string>
<!-- S-13 row subtitle -->
<string name="settings_profile_summary">Name and picture</string>
<!-- S-14; %1$s partner -->
<string name="settings_profile_hint">This is how %1$s sees you in Kvitt.</string>
<string name="settings_categories">Categories</string>
<!-- plural one; S-13 row subtitle -->
<plurals name="settings_categories_summary">
<item quantity="one">%d active</item>
<item quantity="other">%d active</item>
</plurals>
<string name="settings_recurring">Recurring expenses</string>
<!-- plural one; S-13 row subtitle, joined with common_separator_comma -->
<plurals name="settings_recurring_summary_active">
<item quantity="one">%d active</item>
<item quantity="other">%d active</item>
</plurals>
<!-- plural one; shown when > 0 -->
<plurals name="settings_recurring_summary_paused">
<item quantity="one">%d paused</item>
<item quantity="other">%d paused</item>
</plurals>
<string name="settings_devices">Devices</string>
<!-- plural one; S-13 row subtitle -->
<plurals name="settings_devices_summary">
<item quantity="one">%d device</item>
<item quantity="other">%d devices</item>
</plurals>
<string name="settings_pair_device">Pair a device</string>
<string name="settings_sync_status">Sync status</string>
<string name="settings_about">About Kvitt</string>
<!-- S-13 row subtitle; version name -->
<string name="settings_about_summary">Version %1$s</string>
<string name="settings_pending_badge">%d waiting</string>
<string name="settings_update_badge">Update available</string>
<string name="settings_categories_title">Categories</string>
<string name="settings_categories_new">New category</string>
<string name="settings_categories_archived">Archived (%d)</string>
<string name="settings_categories_reorder_offline">Needs connection to change</string>
<!-- TalkBack custom action -->
<string name="settings_categories_move_up">Move up</string>
<!-- TalkBack custom action -->
<string name="settings_categories_move_down">Move down</string>
<!-- TalkBack of the drag handle -->
<string name="settings_categories_drag_handle">Move %1$s</string>
<string name="settings_category_editor_new">New category</string>
<string name="settings_category_editor_edit">Edit category</string>
<!-- badge in the editor of an archived category -->
<string name="settings_category_archived_badge">Archived</string>
<!-- AC-7.4 -->
<string name="settings_category_editor_reset_name">Reset name</string>
<!-- AC-7.4 -->
<string name="settings_category_editor_default_name">Default name: %1$s</string>
<string name="settings_category_editor_icon">Icon</string>
<string name="settings_category_editor_color">Colour</string>
<string name="settings_category_editor_archive">Archive</string>
<string name="settings_category_editor_unarchive">Restore from archive</string>
<string name="settings_category_editor_delete">Delete category</string>
<!-- confirmation dialog; %1$s category name -->
<string name="settings_category_delete_title">Delete the category %1$s?</string>
<!-- confirmation dialog body -->
<string name="settings_category_delete_body">It disappears from the list for both of you. This cannot be undone.</string>
<!-- category_is_default -->
<string name="settings_category_delete_blocked_default">Default categories cannot be deleted, only archived</string>
<!-- inline reason under the disabled delete -->
<string name="settings_category_delete_blocked_default_short">Cannot be deleted: default category</string>
<!-- category_in_use -->
<string name="settings_category_delete_blocked_in_use">The category is used by expenses or recurring expenses. Archive it instead.</string>
<!-- category_in_use, shown for an archived category -->
<string name="settings_category_delete_blocked_in_use_archived">The category is used by expenses or recurring expenses and cannot be deleted.</string>
<string name="settings_about_up_to_date">You have the latest version</string>
<string name="settings_about_check_update">Check for update</string>
<string name="settings_recurring_title">Recurring expenses</string>
<!-- S-19 FAB -->
<string name="settings_recurring_new">New recurring</string>
<!-- %1$s = settings_recurring_day_ordinal_a or _e -->
<string name="settings_recurring_every_month_on">Every month on day %1$s</string>
<!-- sv ordinal for every day not covered by _a -->
<string name="settings_recurring_day_ordinal_e">%1$d</string>
<!-- sv ordinal for days 1, 2, 21, 22, 31 -->
<string name="settings_recurring_day_ordinal_a">%1$d</string>
<!-- %1$s list date -->
<string name="settings_recurring_next">Next %1$s</string>
<!-- list row; %1$s account name -->
<string name="settings_recurring_row_you_pay">%1$s · You pay</string>
<!-- list row; %1$s account, %2$s partner -->
<string name="settings_recurring_row_partner_pays">%1$s · %2$s pays</string>
<string name="settings_recurring_paused">Paused</string>
<string name="settings_recurring_empty_title">No recurring expenses</string>
<string name="settings_recurring_empty_body">Rent, electricity and streaming can be added automatically every month.</string>
<string name="settings_recurring_empty_action">New recurring expense</string>
<string name="settings_recurring_editor_new">New recurring expense</string>
<!-- S-19 editor title when editing -->
<string name="settings_recurring_editor_edit">Recurring expense</string>
<!-- editor section title -->
<string name="settings_recurring_schedule">Schedule</string>
<string name="settings_recurring_day_of_month">Day of month</string>
<!-- days 29–31 -->
<string name="settings_recurring_day_clamp_hint">In shorter months the last day is used</string>
<string name="settings_recurring_first_month">First month</string>
<string name="settings_recurring_last_month">Last month (optional)</string>
<!-- placeholder of the last-month field -->
<string name="settings_recurring_no_end">No last month</string>
<string name="settings_recurring_paused_switch">Paused</string>
<!-- §9.5; %1$s = settings_recurring_day_ordinal_a or _e -->
<string name="settings_recurring_created_at">Created at 06:00 on day %1$s every month</string>
<!-- §9.5 -->
<string name="settings_recurring_resume_hint">Months that passed while paused are not created afterwards.</string>
<!-- AC-9.4 -->
<string name="settings_recurring_edit_hint">The change applies to upcoming expenses.</string>
<string name="settings_recurring_delete_title">Delete %1$s?</string>
<string name="settings_recurring_delete_body">Expenses already created stay.</string>
<string name="settings_recurring_offline_banner">Needs connection. Recurring expenses can only be changed online.</string>
<string name="settings_recurring_error_end_before_start">The last month cannot be before the first</string>
<string name="settings_recurring_error_start_too_early">At most 12 months back</string>
<string name="settings_devices_title">Devices</string>
<string name="settings_devices_paired">Paired %1$s</string>
<string name="settings_devices_last_seen">Last seen %1$s</string>
<string name="settings_devices_app_version">Kvitt %1$s</string>
<!-- AC-3.1 -->
<string name="settings_devices_this_device">This device</string>
<!-- row button and confirm button (AC-3.2) -->
<string name="settings_devices_revoke">Remove access</string>
<!-- AC-3.2 -->
<string name="settings_devices_cannot_revoke_current">You cannot remove access for the phone you are using right now.</string>
<!-- %1$s device name -->
<string name="settings_devices_revoke_title">Remove access for %1$s?</string>
<!-- one of the viewer's own other devices -->
<string name="settings_devices_revoke_body_own">The phone is signed out and all Kvitt data on it is deleted. You can pair it again with a new invite code.</string>
<!-- admin revoking the partner's device; sv genitive as onboarding_profile_joining -->
<string name="settings_devices_revoke_body_partner">%1$s\'s phone is signed out and all Kvitt data on it is deleted. %1$s can pair it again with a new invite code.</string>
<string name="settings_devices_offline">Needs connection to show devices</string>
<string name="settings_devices_loading">Loading devices…</string>
<string name="settings_invites_title_device">Pair a device</string>
<!-- created invite card title -->
<string name="settings_invites_new_device_for">New device for %1$s</string>
<!-- created partner invite card title -->
<string name="settings_invites_new_partner">Invite for your partner</string>
<!-- S-21 create card -->
<string name="settings_invites_create_body">Create a one-time code to pair another phone. The code is valid for 72 hours.</string>
<string name="settings_invites_for_me">For me</string>
<string name="settings_invites_for_partner">For %1$s</string>
<string name="settings_invites_valid_until">Valid until %1$s</string>
<string name="settings_invites_share">Share</string>
<string name="settings_invites_copy_code">Copy code</string>
<string name="settings_invites_code_copied">Code copied</string>
<string name="settings_invites_instructions">Open the link on the new phone, or enter the code in Kvitt. The code works once.</string>
<string name="settings_invites_share_message">Join our household in Kvitt: %1$s The code is %2$s.</string>
<string name="settings_invites_pending">Pending invites</string>
<string name="settings_invites_none">No pending invites</string>
<!-- §9.9 -->
<string name="settings_invites_offline_banner">Needs connection. Invites can only be created and shown online.</string>
<string name="settings_invites_kind_partner">Partner</string>
<!-- pending invite row; %1$s the user the device is for -->
<string name="settings_invites_kind_device">Device · %1$s</string>
<!-- %1$s time_today_at or list date and time -->
<string name="settings_invites_created">Created %1$s</string>
<string name="settings_invites_code_hidden">The code is only shown when it is created</string>
<!-- TalkBack of the Avbryt (common_cancel) button on a pending invite row -->
<string name="settings_invites_cancel">Cancel invite</string>
<string name="settings_invites_cancel_title">Cancel the invite?</string>
<string name="settings_about_title">About Kvitt</string>
<!-- version name and code -->
<string name="settings_about_version">Kvitt %1$s (%2$d)</string>
<string name="settings_about_server">Server</string>
<!-- label; value from /health -->
<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>