M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)

Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
@@ -7,7 +7,6 @@ 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
@@ -16,6 +15,7 @@ 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.UpdateCheck
import se.hagstedt.kvitt.model.UpdateRepository
import se.hagstedt.kvitt.model.UpdateState
import se.hagstedt.kvitt.core.design.R as DesignR
@@ -37,8 +37,8 @@ class AboutViewModel @Inject constructor(
}.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.
* "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner (Available), "Du har den
* senaste versionen" (UpToDate, AC-16.6) or "Kunde inte nå servern" (Failed).
*/
fun check() {
val s = state.value ?: return
@@ -49,9 +49,12 @@ class AboutViewModel @Inject constructor(
}
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) }
val message = when (update.check()) {
UpdateCheck.UpToDate -> UiMessage(R.string.settings_about_up_to_date)
UpdateCheck.Failed -> UiMessage(DesignR.string.common_could_not_reach_server)
is UpdateCheck.Available -> null
}
local.update { it.copy(checking = false, message = message ?: it.message) }
}
}
@@ -126,7 +126,7 @@ private fun DeviceRow(item: SegmentedItemScope, device: Device, revocable: Boole
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()) },
(device.appVersionName ?: device.appVersionCode?.toString())?.let { stringResource(R.string.settings_devices_app_version, it) },
).joinToString(dot)
Row(
Modifier
@@ -225,11 +225,17 @@ class RecurringEditorViewModel @Inject constructor(
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)
val me = s.options.me.id
val partner = s.options.partner.id
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)
val original = s.original
val result: OnlineResult<*> = if (original == null) {
recurring.create(s.form.newTemplate(me, partner, s.options.today))
} else {
recurring.update(s.form.template(original, me, partner, s.options.today))
}
local.update { it.copy(busy = false) }
when {
result is OnlineResult.Ok -> local.update { it.copy(done = true) }
@@ -7,9 +7,9 @@ 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.NewRecurringTemplate
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). */
@@ -80,16 +80,27 @@ data class RecurringForm(
)
}
fun template(original: RecurringTemplate?, me: UserId, partner: UserId, today: String): RecurringTemplate = RecurringTemplate(
id = original?.id ?: TemplateId(""),
/** A new template for `RecurringRepository.create`, which mints the id (E-28). */
fun newTemplate(me: UserId, partner: UserId, today: String): NewRecurringTemplate = NewRecurringTemplate(
draft = draft(me, partner, today),
dayOfMonth = dayOfMonth ?: 0,
startPeriod = startPeriod(today),
endPeriod = end,
paused = paused,
nextOccurrence = original?.nextOccurrence,
)
/** [original] with the form's values, for `RecurringRepository.update`. */
fun template(original: RecurringTemplate, me: UserId, partner: UserId, today: String): RecurringTemplate {
val fields = newTemplate(me, partner, today)
return original.copy(
draft = fields.draft,
dayOfMonth = fields.dayOfMonth,
startPeriod = fields.startPeriod,
endPeriod = fields.endPeriod,
paused = fields.paused,
)
}
/** 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
@@ -104,6 +104,10 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) {
about("online", SettingsScenarios.loaded),
about("offline", SettingsScenarios.offline),
about("check_up_to_date", SettingsScenarios.loaded) { _, vm -> vm.check() },
about("check_failed", SettingsScenarios.loaded) { r, vm ->
r.checkFails = true
vm.check()
},
about("check_update_found", SettingsScenarios.loaded) { r, vm ->
r.afterCheck = UpdateState.Available(SettingsScenarios.update)
vm.check()