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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user