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
@@ -14,16 +14,19 @@ sealed interface OpenTarget {
/** What an intent asks of the app. */
sealed interface AppLink {
/** An invite link (AC-2.6): S-1 with both fields filled. [serverUrl] null when the link names none. */
data class Invite(val serverUrl: String?, val code: String) : AppLink
/**
* An invite link (AC-2.6): S-1 with both fields filled. [serverUrl] null when the link names none; [adminName] the
* inviting admin from the link's `from` parameter, null in links without one.
*/
data class Invite(val serverUrl: String?, val code: String, val adminName: String? = null) : AppLink
/** A notification tap. */
data class Open(val target: OpenTarget) : AppLink
}
/**
* Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…` and
* `https://icehack.asuscomm.com:8443/i/<CODE>` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader
* Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…[&from=…]` and
* `https://icehack.asuscomm.com:8443/i/<CODE>[?from=…]` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader
* S-1 uses for pasted links), and the notification taps this app builds ([openIntent]).
*/
object AppLinks {
@@ -46,7 +49,7 @@ object AppLinks {
/** An invite link, or null for anything else (a landing link with a bad code, another path, another scheme). */
fun parseUri(uri: String?): AppLink.Invite? {
val text = uri?.trim()?.takeIf { it.startsWith("kvitt:", ignoreCase = true) || it.startsWith("https:", ignoreCase = true) }
return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code) }
return text?.let(InviteLink::parse)?.let { AppLink.Invite(it.serverUrl, it.code, it.adminName) }
}
fun parseOpen(kind: String?, id: String?): AppLink.Open? = when (kind) {
@@ -9,12 +9,6 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.Composable
@@ -23,11 +17,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavGraphBuilder
@@ -40,13 +31,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.AppViewModel
import se.hagstedt.kvitt.KvittEmptyScreen
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.AvatarPictures
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.KvittDestination
import se.hagstedt.kvitt.design.component.KvittHousehold
import se.hagstedt.kvitt.design.component.KvittSnackbar
import se.hagstedt.kvitt.design.component.KvittSnackbarVisuals
import se.hagstedt.kvitt.design.component.showKvittSnackbar
import se.hagstedt.kvitt.design.component.LocalAvatarPictures
import se.hagstedt.kvitt.design.component.LocalKvittClock
@@ -159,22 +147,13 @@ private fun AppGraph(viewModel: AppViewModel, session: SessionState, updateCheck
}
}
Box(Modifier.fillMaxSize()) {
NavHost(nav, startDestination = start) {
onboarding(viewModel, shell)
topLevel(session, shell)
accountsAndExpenses(viewModel, shell)
settings(shell, updateCheckAvailable)
}
// Home shows result snackbars in its own scaffold; every other screen gets them here.
if (route != Routes.HOME) {
SnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding().imePadding()) { data ->
val visuals = data.visuals
val message = if (visuals is KvittSnackbarVisuals) stringResource(visuals.text, *visuals.args.toTypedArray()) else visuals.message
val action = if (visuals is KvittSnackbarVisuals) visuals.action?.let { stringResource(it) } else visuals.actionLabel
KvittSnackbar(message, Modifier.padding(KvittTheme.spacing.s4), action, { data.performAction() })
}
}
// Result snackbars are shown in the scaffold of the screen returned to (Home, S-5, S-8, S-10, S-12, S-16), which
// each host [snackbar] (D-071).
NavHost(nav, startDestination = start) {
onboarding(viewModel, shell)
topLevel(session, shell)
accountsAndExpenses(viewModel, shell)
settings(shell, updateCheckAvailable)
}
}
@@ -210,12 +189,6 @@ private class Shell(val nav: NavHostController, val snackbar: SnackbarHostState,
nav.popBackStack()
}
/** A snackbar text a feature resolved itself (S-7, S-8 hand over a String). */
fun show(message: String?) {
if (message == null) return
scope.launch { snackbar.showSnackbar(message, withDismissAction = false) }
}
/** `:app`'s own snackbars, resolved from resources while they show (language-safe, `showKvittSnackbar`). */
fun show(@StringRes text: Int, args: List<Any> = emptyList(), @StringRes action: Int? = null, onAction: () -> Unit = {}) {
scope.launch {
@@ -235,6 +208,7 @@ private fun NavGraphBuilder.onboarding(viewModel: AppViewModel, shell: Shell) {
},
initialServerUrl = invite?.serverUrl,
initialCode = invite?.code,
initialAdminName = invite?.adminName,
)
}
composable(Routes.PROFILE_STEP) {
@@ -270,7 +244,7 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
AnalyticsScreen(onOpenDrillDown = { shell.nav.navigate(Routes.drillDown(it)) }, onNavigate = shell::top)
}
composable(Routes.DRILL_DOWN, arguments = Routes.drillDownArgs) {
DrillDownScreen(onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, onBack = shell::back)
DrillDownScreen(onOpenExpense = { shell.nav.navigate(Routes.expense(it)) }, onBack = shell::back, snackbarHostState = shell.snackbar)
}
composable(Routes.SETTINGS) {
SettingsHubScreen(
@@ -289,6 +263,7 @@ private fun NavGraphBuilder.topLevel(session: SessionState, shell: Shell) {
onBack = shell::back,
onOpenExpense = { shell.nav.navigate(Routes.expense(it)) },
onOpenSettlement = { shell.nav.navigate(Routes.settlement(it)) },
snackbarHostState = shell.snackbar,
)
}
}
@@ -308,6 +283,7 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell:
shell.show(AccountR.string.account_archived_snackbar, listOf(name), DesignR.string.common_undo) { viewModel.undoArchive(id) }
},
onDeleted = { shell.nav.popBackStack(Routes.HOME, inclusive = false) },
snackbarHostState = shell.snackbar,
)
}
composable(Routes.ACCOUNT_EDITOR, arguments = Routes.accountEditorArgs) { entry ->
@@ -325,12 +301,12 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell:
onClose = shell::back,
onSaved = { _, text ->
shell.back()
shell.show(text)
text?.let { shell.show(it) }
},
onDeleted = { text ->
onDeleted = {
// Deleting from the editor also closes the detail it was opened from.
if (!shell.nav.popBackStack(Routes.EXPENSE, inclusive = true)) shell.back()
shell.show(text)
shell.show(DesignR.string.snackbar_expense_deleted)
},
)
}
@@ -338,10 +314,11 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell:
ExpenseDetailScreen(
onBack = shell::back,
onEdit = { shell.nav.navigate(Routes.expenseEditor(id = it)) },
onDeleted = { text ->
onDeleted = {
shell.back()
shell.show(text)
shell.show(DesignR.string.snackbar_expense_deleted)
},
snackbarHostState = shell.snackbar,
)
}
composable(Routes.SETTLE, arguments = Routes.settleArgs) {
@@ -361,6 +338,7 @@ private fun NavGraphBuilder.accountsAndExpenses(viewModel: AppViewModel, shell:
shell.back()
shell.show(DesignR.string.snackbar_payment_deleted)
},
snackbarHostState = shell.snackbar,
)
}
}
@@ -1,7 +1,7 @@
package se.hagstedt.kvitt.push
import android.content.res.Resources
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.model.PushEvent
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.format.MoneyFormatter
import se.hagstedt.kvitt.link.OpenTarget
@@ -11,7 +11,7 @@ import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.first
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.model.PushEvent
import se.hagstedt.kvitt.link.AppLinks
import se.hagstedt.kvitt.model.ExpenseId
import se.hagstedt.kvitt.model.ExpenseRepository
@@ -9,7 +9,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.adapter.fcm.FcmRegistration
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.model.PushEvent
import se.hagstedt.kvitt.adapter.fcm.PushListener
import se.hagstedt.kvitt.data.api.ApiResult
import se.hagstedt.kvitt.data.api.KvittApi
@@ -9,7 +9,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerFactory
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.model.PushEvent
import se.hagstedt.kvitt.data.sync.SyncTriggers
/**
@@ -33,6 +33,19 @@ class AppLinksTest {
assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), link)
}
@Test
fun linksWithTheAdminNameCarryIt_andOldLinksStillParse() {
assertEquals(
AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Åsa Berg"),
AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN?from=%C3%85sa%20Berg"),
)
assertEquals(
AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Jacob"),
AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&code=ABCDEFGHJKMN&from=Jacob"),
)
assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN")?.adminName)
}
@Test
fun otherLinksAreIgnored() {
assertNull(AppLinks.parseUri(null))
@@ -10,7 +10,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import se.hagstedt.kvitt.adapter.fcm.PushEvent
import se.hagstedt.kvitt.model.PushEvent
import se.hagstedt.kvitt.link.OpenTarget
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.User