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:
-14
@@ -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)
|
||||
}
|
||||
+152
@@ -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)
|
||||
}
|
||||
}
|
||||
+68
@@ -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)
|
||||
}
|
||||
+206
@@ -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)
|
||||
}
|
||||
+227
@@ -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"
|
||||
}
|
||||
}
|
||||
+285
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+132
@@ -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,
|
||||
)
|
||||
}
|
||||
+118
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+58
@@ -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)
|
||||
}
|
||||
}
|
||||
+191
@@ -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,
|
||||
)
|
||||
}
|
||||
+107
@@ -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)
|
||||
}
|
||||
+170
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -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)
|
||||
}
|
||||
+283
@@ -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
|
||||
}
|
||||
+145
@@ -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,
|
||||
)
|
||||
}
|
||||
+173
@@ -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)
|
||||
}
|
||||
+153
@@ -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,
|
||||
)
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+451
@@ -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)
|
||||
}
|
||||
+279
@@ -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"
|
||||
}
|
||||
}
|
||||
+204
@@ -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
|
||||
}
|
||||
+174
@@ -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
|
||||
|
||||
|
||||
+53
@@ -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)
|
||||
}
|
||||
+12
@@ -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>
|
||||
Reference in New Issue
Block a user