M7a: feature onboarding (S-1, S-2, S-3, S-18) with fakes, unit and screenshot tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-18 10:41:45 +02:00
parent 64539324e8
commit 1ea752cd80
26 changed files with 2517 additions and 15 deletions
@@ -0,0 +1,80 @@
package se.hagstedt.kvitt.fakes.onboarding
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.model.DeviceId
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
/**
* Scenarios of `:feature:onboarding` (S-1, S-2, S-3, S-18; PLAN-M7a §5). Plain data on top of [FakeScenarios.empty];
* the outcomes of online calls that a scenario cannot express (a pairing refused with `invite_invalid`, an upload
* still running) come from [ScriptedSessionRepository], [ScriptedProfileRepository] and [ScriptedInviteRepository].
*/
object OnboardingScenarios {
private val owner = FakeScenarios.owner
private val partner = FakeScenarios.partner
private val partnerDevice = DeviceId("01920000-0000-7000-8000-0000000000d2")
/** S-1 on a phone that was never paired: no session, online. */
val unpaired: FakeScenario = FakeScenarios.empty.copy(session = SessionState.Unpaired)
/** S-1 on the phone of the partner-to-be: pairing returns Anna, a `member` (AC-2.10 leads on to S-2). */
val unpairedPartner: FakeScenario = unpaired.copy(me = partner, partner = owner, deviceId = partnerDevice, devices = emptyList())
/** S-1 offline: pairing never queues, the call ends in the network error (design-spec S-1). */
val unpairedOffline: FakeScenario = unpaired.copy(online = false)
/** S-1 "Para ihop telefonen igen": the server answered 401 `unauthorized`, local data kept (§14.4.8). */
val unrecognised: FakeScenario = FakeScenarios.empty.copy(session = SessionState.Unrecognised)
/** S-18: the server answered 401 `device_revoked` (AC-3.3). */
val revoked: FakeScenario = FakeScenarios.empty.copy(session = SessionState.Revoked)
/** S-2: Anna has just paired with Jacob's partner invite; she is the viewer, no picture yet (AC-2.10). */
val partnerJustPaired: FakeScenario = FakeScenarios.empty.copy(
me = partner,
partner = owner,
deviceId = partnerDevice,
session = SessionState.Paired(partner, owner, partnerDevice),
devices = emptyList(),
)
/** S-2 after a picture was uploaded (`picture_version` 1). */
val partnerWithPicture: FakeScenario = partnerJustPaired.withMe(partnerJustPaired.me.copy(pictureVersion = 1))
/** S-2 with a long Swedish name at the §9.6 limit's order of size. */
val partnerLongName: FakeScenario = partnerJustPaired.withMe(partnerJustPaired.me.copy(displayName = "Anna-Karin Lindqvist Östergren"))
/** S-2 offline: picture and name are online-only (§9.9). */
val partnerOffline: FakeScenario = partnerJustPaired.copy(online = false)
/** S-3: Jacob alone, a partner invite created on 16 October 21:40 that runs until Monday 19 October 21:40 (AC-2.3). */
val waitingWithInvite: FakeScenario = FakeScenarios.empty.copy(
partner = null,
session = SessionState.Paired(owner, null, FakeScenarios.empty.deviceId),
invites = listOf(
Invite(
id = InviteId("01920000-0000-7000-8000-0000000000f1"),
kind = InviteKind.PARTNER,
targetUser = null,
code = null,
link = null,
createdAt = "2026-10-16T19:40:00.000Z",
expiresAt = "2026-10-19T19:40:00.000Z",
),
),
)
/** S-3 without a pending invite. */
val waitingWithoutInvite: FakeScenario = waitingWithInvite.copy(invites = emptyList())
/** S-3 offline: the invite list is online-only, so no invite row (design-spec S-3). */
val waitingOffline: FakeScenario = waitingWithInvite.copy(online = false)
private fun FakeScenario.withMe(me: User): FakeScenario =
copy(me = me, session = SessionState.Paired(me, partner, deviceId))
}
@@ -0,0 +1,91 @@
package se.hagstedt.kvitt.fakes.onboarding
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.flow.Flow
import se.hagstedt.kvitt.fakes.FakeInviteRepository
import se.hagstedt.kvitt.fakes.FakeProfileRepository
import se.hagstedt.kvitt.fakes.FakeSessionRepository
import se.hagstedt.kvitt.fakes.FakeStore
import se.hagstedt.kvitt.model.Invite
import se.hagstedt.kvitt.model.InviteId
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
/**
* How a scripted online call ends. [Succeed] runs the shared fake (which still answers `Offline` when the scenario is
* offline); [Fail] answers `Failed(code, field)` when online; [Hang] never returns, so a screen stays in its progress
* state for a screenshot.
*/
sealed interface CallOutcome {
data object Succeed : CallOutcome
data class Fail(val code: String, val field: String? = null) : CallOutcome
data object Hang : CallOutcome
}
/** The arguments of one [SessionRepository.pair] call, recorded for tests. */
data class PairCall(val serverUrl: String, val code: String, val displayName: String?, val deviceName: String)
private suspend fun <T> FakeStore.scripted(outcome: CallOutcome, succeed: suspend () -> OnlineResult<T>): OnlineResult<T> = when (outcome) {
CallOutcome.Succeed -> succeed()
is CallOutcome.Fail -> if (current.online) OnlineResult.Failed(outcome.code, outcome.field) else OnlineResult.Offline
CallOutcome.Hang -> awaitCancellation()
}
/** [FakeSessionRepository] with a scripted [pair] outcome. */
class ScriptedSessionRepository(private val store: FakeStore, var pairOutcome: CallOutcome = CallOutcome.Succeed) : SessionRepository {
private val delegate = FakeSessionRepository(store)
val pairCalls = mutableListOf<PairCall>()
var acknowledged = 0
private set
override val session: Flow<SessionState> = delegate.session
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> {
pairCalls += PairCall(serverUrl, code, displayName, deviceName)
return store.scripted(pairOutcome) { delegate.pair(serverUrl, code, displayName, deviceName) }
}
override suspend fun acknowledgeRevoked() {
acknowledged += 1
delegate.acknowledgeRevoked()
}
}
/** [FakeProfileRepository] with scripted picture and name outcomes. */
class ScriptedProfileRepository(
private val store: FakeStore,
var pictureOutcome: CallOutcome = CallOutcome.Succeed,
var nameOutcome: CallOutcome = CallOutcome.Succeed,
) : ProfileRepository {
private val delegate = FakeProfileRepository(store)
val namesSet = mutableListOf<String>()
override fun household(): Flow<List<User>> = delegate.household()
override suspend fun setDisplayName(name: String): OnlineResult<Unit> {
namesSet += name
return store.scripted(nameOutcome) { delegate.setDisplayName(name) }
}
override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit> =
store.scripted(pictureOutcome) { delegate.setPicture(bytes, mimeType) }
override suspend fun removePicture(): OnlineResult<Unit> = store.scripted(pictureOutcome) { delegate.removePicture() }
}
/** [FakeInviteRepository] with a scripted [list] outcome (S-3's error state). */
class ScriptedInviteRepository(private val store: FakeStore, var listOutcome: CallOutcome = CallOutcome.Succeed) : InviteRepository {
private val delegate = FakeInviteRepository(store)
override suspend fun list(): OnlineResult<List<Invite>> = store.scripted(listOutcome) { delegate.list() }
override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite> = delegate.create(kind, targetUser)
override suspend fun cancel(id: InviteId): OnlineResult<Unit> = delegate.cancel(id)
}
+26 -1
View File
@@ -1,4 +1,29 @@
// :feature:onboarding — Pairing, partner profile step, waiting for partner, device removed (S-1, S-2, S-3, S-18; M7a). Convention: android/README.md.
// :feature:onboarding — Pairing, partner profile step, waiting for partner, device removed (S-1, S-2, S-3, S-18; M7a).
// Convention: android/README.md. :core:model and :core:design come from the feature plugin; :core:fakes only for debug
// and tests.
plugins {
id("kvitt.android.feature")
alias(libs.plugins.roborazzi)
}
android {
// BuildConfig.DEBUG decides the server-address rule of AC-2.8 (debug builds also accept http:// on the LAN).
buildFeatures { buildConfig = true }
}
roborazzi {
outputDir.set(file("src/test/screenshots"))
}
dependencies {
// The Android photo picker (AC-4.3) through ActivityResultContracts.PickVisualMedia.
implementation(libs.androidx.activity.compose)
debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.androidx.compose.ui.test.junit4)
testImplementation(libs.robolectric)
testImplementation(libs.roborazzi)
testImplementation(libs.roborazzi.compose)
testImplementation(libs.roborazzi.junit.rule)
}
@@ -0,0 +1,58 @@
package se.hagstedt.kvitt.feature.onboarding
import se.hagstedt.kvitt.design.component.INVITE_LENGTH
import se.hagstedt.kvitt.design.component.normalizeInviteCode
import java.net.URI
import java.net.URISyntaxException
import java.net.URLDecoder
/** What "Klistra in" found on the clipboard: a normalised 12-character code and, from a link, the server. */
data class PastedInvite(val code: String, val serverUrl: String?)
/**
* Reads pasted text on S-1 (design-spec S-1 "Klistra in"): an invite link `kvitt://invite?server=…&code=…`, a landing
* link `https://<server>/i/<CODE>` (AC-2.5, AC-2.6), or a bare code in any spelling §8.3 normalises. Anything else is
* null. Opening the app from a link is `:app`'s; this only reads the clipboard.
*/
object InviteLink {
fun parse(text: String): PastedInvite? {
val trimmed = text.trim()
val uri = try {
URI(trimmed)
} catch (_: URISyntaxException) {
null
}
return when (uri?.scheme?.lowercase()) {
"kvitt" -> fromAppLink(uri)
"http", "https" -> fromLandingLink(uri)
else -> code(trimmed)?.let { PastedInvite(it, null) }
}
}
private fun fromAppLink(uri: URI): PastedInvite? {
if (uri.schemeSpecificPart?.startsWith("//invite") != true) return null
val query = uri.rawQuery ?: return null
val params = query.split('&').mapNotNull { pair ->
val key = pair.substringBefore('=', "")
if (key.isEmpty()) null else key to URLDecoder.decode(pair.substringAfter('='), Charsets.UTF_8)
}.toMap()
val code = params["code"]?.let(::code) ?: return null
return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() })
}
private fun fromLandingLink(uri: URI): PastedInvite? {
val path = uri.rawPath ?: return null
val segments = path.trim('/').split('/')
if (segments.size != 2 || segments[0] != "i") return null
val code = code(segments[1]) ?: return null
val port = if (uri.port == -1) "" else ":${uri.port}"
return PastedInvite(code, "${uri.scheme.lowercase()}://${uri.host}$port")
}
/** The normalised code when [text] normalises to exactly 12 alphabet characters (§8.3), else null. */
private fun code(text: String): String? {
val stripped = text.uppercase().filter { it != '-' && !it.isWhitespace() }
val normalised = normalizeInviteCode(stripped)
return normalised.takeIf { it.length == INVITE_LENGTH && stripped.length == INVITE_LENGTH }
}
}
@@ -0,0 +1,41 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.compose.ui.unit.dp
/**
* Measures of design-spec §4 S-1, S-2 and S-18 that `:core:design`'s `KvittDimens` does not name yet. Kept in this one
* file so they move to `KvittDimens` in one step (contract request in the M7a report); nothing else in this module
* writes a size.
*/
internal object OnboardingDimens {
// S-1 brand block: padding 28 / 0 / 12, app mark 88 with `receipt_long` 44.
val brandTop = 28.dp
val brandBottom = 12.dp
val appMark = 88.dp
val appMarkIcon = 44.dp
// S-1 column gap 14; bottom bar padding 12 / 16; progress indicator in the button 20 (stroke 2.5).
val columnGap = 14.dp
val bottomBarVertical = 12.dp
val bottomBarHorizontal = 16.dp
val buttonProgress = 20.dp
val buttonProgressStroke = 2.5.dp
// S-1 help line icon 20.
val helpIcon = 20.dp
// S-2 intro padding 24 / 4 / 4; card padding 22 / 20, gap 14; avatar badge 40 with icon 20 and a 3 dp card ring.
val introTop = 24.dp
val introSide = 4.dp
val introBottom = 4.dp
val cardVertical = 22.dp
val cardHorizontal = 20.dp
val avatarBadge = 40.dp
val avatarBadgeIcon = 20.dp
val avatarBadgeRing = 3.dp
// S-18: padding top 80, gap 14, tile 112 with `phonelink_erase` 52.
val removedTop = 80.dp
val removedTile = 112.dp
val removedIcon = 52.dp
}
@@ -1,14 +0,0 @@
package se.hagstedt.kvitt.feature.onboarding
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 OnboardingEntry(modifier: Modifier = Modifier) {
Box(modifier)
}
@@ -0,0 +1,74 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
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.text.style.TextAlign
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.KvittButton
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
/** The pinned bottom bar of S-1, S-2 and S-18: padding 12 / 16 around one full-width button. */
@Composable
internal fun BottomActionBar(content: @Composable () -> Unit) {
Box(
Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = OnboardingDimens.bottomBarHorizontal, vertical = OnboardingDimens.bottomBarVertical),
) { content() }
}
/** The full-width filled medium button of the bottom bars. */
@Composable
internal fun BottomButton(text: String, onClick: () -> Unit, icon: KvittIcon?, enabled: Boolean = true) {
KvittButton(text, onClick, Modifier.fillMaxWidth(), kind = ButtonKind.Filled, size = ButtonSize.Medium, icon = icon, enabled = enabled)
}
/**
* S-1's "Para ihop" while pairing (design-spec S-1 "loading"): the filled medium button keeps its `primary` fill and
* shows a 20 dp progress indicator in `on-primary` with "Parar ihop…"; it is not clickable. `KvittButton` has no
* progress form (contract request in the M7a report), so this draws the same button with Material's `Button`.
*/
@Composable
internal fun ProgressBottomButton(text: String) {
val scheme = MaterialTheme.colorScheme
Button(
onClick = {},
modifier = Modifier.fillMaxWidth().heightIn(min = KvittDimens.buttonMedium),
enabled = true,
shape = CircleShape,
colors = ButtonDefaults.buttonColors(containerColor = scheme.primary, contentColor = scheme.onPrimary),
contentPadding = PaddingValues(horizontal = KvittDimens.buttonMediumHorizontal),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator(
Modifier.size(OnboardingDimens.buttonProgress),
color = scheme.onPrimary,
strokeWidth = OnboardingDimens.buttonProgressStroke,
)
Spacer(Modifier.width(KvittTheme.spacing.s2))
Text(text, style = KvittTheme.typography.buttonMedium, textAlign = TextAlign.Center, maxLines = 2)
}
}
}
@@ -0,0 +1,284 @@
package se.hagstedt.kvitt.feature.onboarding
import android.os.Build
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.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
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.core.design.R as DesignR
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.InviteCodeField
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
/** What S-1 can be asked to do; the stateless [PairingContent] calls these. */
data class PairingActions(
val onCodeChange: (String) -> Unit = {},
val onNameChange: (String) -> Unit = {},
val onServerChange: (String) -> Unit = {},
val onToggleServer: () -> Unit = {},
val onPaste: () -> Unit = {},
val onPair: () -> Unit = {},
val onErrorShown: () -> Unit = {},
)
/**
* S-1 Pairing (design-spec S-1). [initialServerUrl] and [initialCode] come from an invite link that `:app` parsed
* (AC-2.6); [onPaired] receives where to go next (S-2 for a partner, AC-2.10; else Home).
*/
@Composable
fun PairingScreen(
onPaired: (PairingDone) -> Unit,
modifier: Modifier = Modifier,
initialServerUrl: String? = null,
initialCode: String? = null,
viewModel: PairingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val clipboard = LocalClipboard.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
LaunchedEffect(initialServerUrl, initialCode) { viewModel.prefill(initialServerUrl, initialCode) }
LaunchedEffect(state.done) {
state.done?.let {
onPaired(it)
viewModel.onDoneHandled()
}
}
PairingContent(
state = state,
actions = PairingActions(
onCodeChange = viewModel::onCodeChange,
onNameChange = viewModel::onNameChange,
onServerChange = viewModel::onServerChange,
onToggleServer = viewModel::onToggleServer,
onPaste = {
scope.launch {
val clip = clipboard.getClipEntry()?.clipData
val text = clip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString()
viewModel.onPaste(text)
}
},
onPair = { viewModel.pair(Build.MODEL) },
onErrorShown = viewModel::onErrorShown,
),
modifier = modifier,
)
}
/** The stateless S-1, rendered by previews and screenshot tests. */
@Composable
fun PairingContent(
state: PairingUiState,
actions: PairingActions,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val snackbarText = when (state.error) {
PairingError.AppTooOld -> stringResource(DesignR.string.error_app_too_old)
PairingError.Server -> stringResource(DesignR.string.error_server)
PairingError.Validation -> stringResource(DesignR.string.error_validation)
else -> null
}
LaunchedEffect(snackbarText) {
if (snackbarText != null) {
snackbarHostState.showSnackbar(snackbarText, duration = SnackbarDuration.Short)
actions.onErrorShown()
}
}
KvittScaffold(
modifier = modifier,
snackbarHostState = snackbarHostState,
bottomBar = {
BottomActionBar {
if (state.pairing) {
ProgressBottomButton(stringResource(R.string.onboarding_pairing))
} else {
BottomButton(stringResource(R.string.onboarding_pair), actions.onPair, KvittIcon.Link, enabled = state.canPair)
}
}
},
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter),
verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap),
) {
Brand(state.mode)
val codeError = when (state.error) {
PairingError.InviteInvalid -> stringResource(R.string.onboarding_error_invite_invalid)
PairingError.RateLimited -> stringResource(R.string.onboarding_error_rate_limited)
PairingError.CodeLength -> stringResource(R.string.onboarding_error_code_length)
else -> null
}
InviteCodeField(
value = state.code,
onValueChange = actions.onCodeChange,
pasteLabel = stringResource(R.string.onboarding_code_paste),
onPaste = actions.onPaste,
modifier = Modifier.announced(codeError),
error = codeError,
enabled = !state.pairing,
)
if (state.nameRequired) {
KvittTextField(
value = state.name,
onValueChange = actions.onNameChange,
label = stringResource(R.string.onboarding_name_label),
error = if (state.nameTooLong) {
pluralStringResource(DesignR.plurals.error_too_long, PairingUiState.NAME_MAX, PairingUiState.NAME_MAX)
} else {
null
},
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done,
onIme = actions.onPair,
readOnly = state.pairing,
)
}
HelpLine()
val serverError = when (state.error) {
PairingError.Network -> stringResource(R.string.onboarding_error_network)
PairingError.HttpsRequired -> stringResource(R.string.onboarding_error_https)
else -> null
}
if (state.serverExpanded || serverError != null) {
KvittTextField(
value = state.serverUrl,
onValueChange = actions.onServerChange,
label = stringResource(R.string.onboarding_server_label),
modifier = Modifier.announced(serverError),
error = serverError,
capitalization = KeyboardCapitalization.None,
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Done,
readOnly = state.pairing,
)
} else {
ServerRow(state.serverUrl, enabled = !state.pairing, onClick = actions.onToggleServer)
}
}
}
}
/** Errors are announced (design-spec S-1 TalkBack: live region assertive). */
private fun Modifier.announced(error: String?): Modifier =
if (error == null) this else semantics { liveRegion = LiveRegionMode.Assertive }
/** App mark, "Kvitt" and the tagline, or in the Unrecognised variant the "pair again" title and body. */
@Composable
private fun Brand(mode: PairingMode) {
val scheme = MaterialTheme.colorScheme
Column(
Modifier.fillMaxWidth().padding(top = OnboardingDimens.brandTop, bottom = OnboardingDimens.brandBottom),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
Box(
Modifier.size(OnboardingDimens.appMark).background(scheme.primary, KvittTheme.shapes.avatarLarge),
contentAlignment = Alignment.Center,
) {
KvittIconImage(KvittIcon.ReceiptLong, null, filled = true, size = OnboardingDimens.appMarkIcon, tint = scheme.onPrimary)
}
Text(stringResource(DesignR.string.common_wordmark), style = MaterialTheme.typography.displaySmall, color = scheme.primary)
if (mode == PairingMode.Again) {
Text(stringResource(R.string.onboarding_again_title), style = MaterialTheme.typography.headlineSmall, textAlign = TextAlign.Center)
Text(
stringResource(R.string.onboarding_again_body),
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
} else {
Text(
stringResource(DesignR.string.common_tagline),
style = MaterialTheme.typography.titleMedium,
color = scheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
/**
* Where the code comes from. The admin's name is not known before pairing (an invite link carries only server and
* code, AC-2.6), so the generic line is used (`onboarding_code_help_generic`; spec gap in the M7a report).
*/
@Composable
private fun HelpLine() {
val muted = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2),
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
KvittIconImage(KvittIcon.Info, null, size = OnboardingDimens.helpIcon, tint = muted)
Text(stringResource(R.string.onboarding_code_help_generic), style = MaterialTheme.typography.bodyMedium, color = muted)
}
}
/** The collapsed server row: `dns`, "Serveradress" over the address, `expand_more`; tap opens the field. */
@Composable
private fun ServerRow(url: String, enabled: Boolean, onClick: () -> Unit) {
val scheme = MaterialTheme.colorScheme
val label = stringResource(R.string.onboarding_server_label)
val spoken = label + stringResource(DesignR.string.common_separator_comma) + url
Row(
Modifier
.fillMaxWidth()
.heightIn(min = KvittTheme.spacing.rowMin)
.background(scheme.surfaceContainerLow, KvittTheme.shapes.menu)
.clickable(enabled = enabled, onClickLabel = stringResource(DesignR.string.common_edit), onClick = onClick)
.semantics(mergeDescendants = true) { contentDescription = spoken }
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
KvittIconImage(KvittIcon.Dns, null, tint = scheme.onSurfaceVariant)
Column(Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.labelMedium, color = scheme.onSurfaceVariant)
Text(url, style = MaterialTheme.typography.bodyMedium)
}
KvittIconImage(KvittIcon.ExpandMore, null, size = KvittDimens.icon, tint = scheme.onSurfaceVariant)
}
}
@@ -0,0 +1,198 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.design.component.INVITE_LENGTH
import se.hagstedt.kvitt.design.component.normalizeInviteCode
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.Role
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import javax.inject.Inject
/** S-1's two forms: a first pairing (with "Ditt namn") or "Para ihop telefonen igen" after 401 `unauthorized` (§14.4.8). */
enum class PairingMode { First, Again }
/** A failed pairing, by where it is shown (design-spec S-1 "Interactions"). */
enum class PairingError {
// Under the code field.
InviteInvalid,
RateLimited,
CodeLength,
// Under the server row, which expands.
Network,
HttpsRequired,
// Snackbar: errors no field is at fault for (design-spec §1 conventions).
AppTooOld,
Server,
Validation,
}
/** Where S-1 goes after a successful pairing. */
enum class PairingDone {
/** A `member` on a first pairing confirms the profile first (AC-2.10). */
ProfileStep,
/** Everyone else goes to Home (its loading state until the first sync ends). */
Home,
}
/** The state of S-1. [code] is normalised (§8.3), at most 12 characters, without hyphens. */
data class PairingUiState(
val mode: PairingMode = PairingMode.First,
val code: String = "",
val name: String = "",
val serverUrl: String = RELEASE_SERVER_URL,
val serverExpanded: Boolean = false,
val pairing: Boolean = false,
val error: PairingError? = null,
val done: PairingDone? = null,
) {
val nameRequired: Boolean get() = mode == PairingMode.First
/** The display name is 1 to 40 code points after trimming (§9.6). */
val nameTooLong: Boolean get() = nameRequired && name.trim().let { it.codePointCount(0, it.length) } > NAME_MAX
/** "Para ihop" is enabled when the code has 12 characters, the name is set (first pairing), the address is well formed. */
val canPair: Boolean
get() = !pairing && code.length == INVITE_LENGTH && ServerAddress.isWellFormed(serverUrl) &&
(!nameRequired || (name.isNotBlank() && !nameTooLong))
companion object {
const val NAME_MAX = 40
}
}
/**
* State holder of S-1 Pairing (design-spec S-1; AC-2.4, AC-2.6 to AC-2.8, AC-2.10; §8.3; §14.4.8). Pairing never
* queues: offline, the call reports the network error. [allowLanHttp] is true in debug builds (AC-2.8).
*/
@HiltViewModel
class PairingViewModel internal constructor(
private val session: SessionRepository,
private val allowLanHttp: Boolean,
) : ViewModel() {
@Inject
constructor(session: SessionRepository) : this(session, BuildConfig.DEBUG)
private val mutable = MutableStateFlow(PairingUiState())
val state: StateFlow<PairingUiState> = mutable.asStateFlow()
private var prefilled = false
init {
viewModelScope.launch {
session.session.collect { s ->
mutable.update { it.copy(mode = if (s is SessionState.Unrecognised) PairingMode.Again else PairingMode.First) }
}
}
}
/**
* Fills the fields from an invite link once (AC-2.6); `:app` parses the intent. The server row opens expanded when
* the link names a server other than the release default (design-spec S-1).
*/
fun prefill(serverUrl: String?, code: String?) {
if (prefilled || (serverUrl == null && code == null)) return
prefilled = true
mutable.update { s ->
val server = serverUrl?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() }
s.copy(
code = code?.let(::normalizeInviteCode) ?: s.code,
serverUrl = server ?: s.serverUrl,
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
)
}
}
fun onCodeChange(text: String) = mutable.update {
it.copy(code = normalizeInviteCode(text), error = it.error.takeUnless { e -> e.isCodeError })
}
fun onNameChange(text: String) = mutable.update { it.copy(name = text) }
fun onServerChange(text: String) = mutable.update {
it.copy(serverUrl = text, error = it.error.takeUnless { e -> e.isServerError })
}
fun onToggleServer() = mutable.update { it.copy(serverExpanded = !it.serverExpanded) }
/** "Klistra in": a code or an invite link fills code (and server); anything else says the code has 12 characters. */
fun onPaste(text: String?) {
val invite = text?.let(InviteLink::parse)
mutable.update { s ->
if (invite == null) {
s.copy(error = PairingError.CodeLength)
} else {
val server = invite.serverUrl
s.copy(
code = invite.code,
serverUrl = server ?: s.serverUrl,
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
error = null,
)
}
}
}
/** "Para ihop" with the phone's model as the device name (`Build.MODEL`, design-spec S-1). */
fun pair(deviceName: String) {
val s = mutable.value
if (!s.canPair) return
val server = ServerAddress.normalise(s.serverUrl)
if (ServerAddress.check(server, allowLanHttp) == ServerAddressProblem.HttpsRequired) {
mutable.update { it.copy(error = PairingError.HttpsRequired, serverExpanded = true) }
return
}
val name = if (s.nameRequired) s.name.trim() else null
mutable.update { it.copy(pairing = true, error = null) }
viewModelScope.launch {
val result = session.pair(server, s.code, name, deviceName)
mutable.update { current ->
when (result) {
is OnlineResult.Ok -> current.copy(
pairing = false,
done = if (s.mode == PairingMode.First && result.value.role == Role.MEMBER) PairingDone.ProfileStep else PairingDone.Home,
)
OnlineResult.Offline -> current.copy(pairing = false, error = PairingError.Network, serverExpanded = true)
is OnlineResult.Failed -> {
val error = pairingError(result.code)
current.copy(pairing = false, error = error, serverExpanded = current.serverExpanded || error.isServerError)
}
}
}
}
}
/** The screen has navigated after [PairingUiState.done]. */
fun onDoneHandled() = mutable.update { it.copy(done = null) }
/** The snackbar error has been shown. */
fun onErrorShown() = mutable.update { if (it.error?.isSnackbar == true) it.copy(error = null) else it }
}
/** §14.3.2 error code of E-3 (or `network`, D-029) to S-1's error. */
internal fun pairingError(code: String): PairingError = when (code) {
"invite_invalid" -> PairingError.InviteInvalid
"rate_limited" -> PairingError.RateLimited
"network" -> PairingError.Network
"protocol_unsupported" -> PairingError.AppTooOld
"internal", "unavailable", "not_implemented" -> PairingError.Server
else -> PairingError.Validation
}
internal val PairingError?.isCodeError: Boolean
get() = this == PairingError.InviteInvalid || this == PairingError.RateLimited || this == PairingError.CodeLength
internal val PairingError?.isServerError: Boolean
get() = this == PairingError.Network || this == PairingError.HttpsRequired
internal val PairingError?.isSnackbar: Boolean
get() = this == PairingError.AppTooOld || this == PairingError.Server || this == PairingError.Validation
@@ -0,0 +1,285 @@
package se.hagstedt.kvitt.feature.onboarding
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
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.core.design.R as DesignR
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.KvittCard
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTextField
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.PersonAvatar
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.design.icon.KvittIcon
import se.hagstedt.kvitt.design.theme.KvittDimens
import se.hagstedt.kvitt.model.User
import java.io.IOException
/** What S-2 can be asked to do; the stateless [ProfileStepContent] calls these. */
data class ProfileStepActions(
val onNameChange: (String) -> Unit = {},
val onPickPicture: () -> Unit = {},
val onRemovePicture: () -> Unit = {},
val onContinue: () -> Unit = {},
val onMessageShown: () -> Unit = {},
)
/** S-2 Partner profile step (design-spec S-2, AC-2.10). [onDone] goes to Home. Back is not offered. */
@Composable
fun ProfileStepScreen(
onDone: () -> Unit,
modifier: Modifier = Modifier,
viewModel: ProfileStepViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
BackHandler {}
LaunchedEffect(state.done) {
if (state.done) {
onDone()
viewModel.onDoneHandled()
}
}
val picker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri: Uri? ->
if (uri != null) {
scope.launch {
val resolver = context.contentResolver
val picked = withContext(Dispatchers.IO) {
try {
resolver.openInputStream(uri)?.use { it.readBytes() }?.let { it to (resolver.getType(uri) ?: "") }
} catch (_: IOException) {
null
} catch (_: SecurityException) {
null
}
}
if (picked == null) viewModel.onPictureUnreadable() else viewModel.onPicturePicked(picked.first, picked.second)
}
}
}
ProfileStepContent(
state = state,
actions = ProfileStepActions(
onNameChange = viewModel::onNameChange,
onPickPicture = { picker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) },
onRemovePicture = viewModel::onRemovePicture,
onContinue = viewModel::onContinue,
onMessageShown = viewModel::onMessageShown,
),
modifier = modifier,
)
}
/** The stateless S-2, rendered by previews and screenshot tests. */
@Composable
fun ProfileStepContent(
state: ProfileStepUiState,
actions: ProfileStepActions,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val message = state.message?.let { stringResource(it.text) }
LaunchedEffect(message) {
if (message != null) {
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
actions.onMessageShown()
}
}
val me = state.me
val title = stringResource(R.string.onboarding_profile_title)
KvittScaffold(
modifier = modifier.semantics { paneTitle = title },
snackbarHostState = snackbarHostState,
topBar = { Spacer(Modifier.statusBarsPadding().height(KvittTheme.spacing.topAppBarHeight)) },
bottomBar = {
BottomActionBar { BottomButton(stringResource(DesignR.string.common_continue), actions.onContinue, null, enabled = state.canContinue) }
},
) { padding ->
if (me == null) return@KvittScaffold
ProvideHousehold(me, state.owner) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
) {
state.owner?.let { Intro(it) }
ProfileCard(state, actions)
}
}
}
}
@Composable
private fun Intro(owner: User) {
val ownerName = owner.displayName
Column(
Modifier.fillMaxWidth().padding(
start = OnboardingDimens.introSide,
end = OnboardingDimens.introSide,
top = OnboardingDimens.introTop,
bottom = OnboardingDimens.introBottom,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2),
) {
PersonAvatar(owner, KvittDimens.avatarIntro, shape = AvatarShape.Large)
Text(
stringResource(R.string.onboarding_profile_joining, genitiveArgument(ownerName, kvittLanguage())),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.onboarding_profile_hint, ownerName),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@Composable
private fun ProfileCard(state: ProfileStepUiState, actions: ProfileStepActions) {
val me = state.me ?: return
KvittCard(
Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = OnboardingDimens.cardHorizontal, vertical = OnboardingDimens.cardVertical),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap),
) {
Box {
PersonAvatar(
me,
KvittDimens.avatarProfileStep,
shape = AvatarShape.Large,
state = if (state.uploading) AvatarState.Uploading else AvatarState.Idle,
)
// The badge is part of the "Lägg till bild" button for TalkBack (design-spec S-2), so it is decorative here.
Box(
Modifier
.align(Alignment.BottomEnd)
.size(OnboardingDimens.avatarBadge)
.border(OnboardingDimens.avatarBadgeRing, KvittTheme.colors.card, CircleShape)
.padding(OnboardingDimens.avatarBadgeRing)
.background(MaterialTheme.colorScheme.primary, CircleShape)
.clearAndSetSemantics {},
contentAlignment = Alignment.Center,
) {
KvittIconImage(KvittIcon.AddAPhoto, null, size = OnboardingDimens.avatarBadgeIcon, tint = MaterialTheme.colorScheme.onPrimary)
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s1)) {
if (state.hasPicture) {
KvittButton(
stringResource(DesignR.string.profile_change_picture),
actions.onPickPicture,
kind = ButtonKind.Tonal,
icon = KvittIcon.PhotoLibrary,
enabled = state.canChangePicture,
)
KvittButton(
stringResource(DesignR.string.profile_remove_picture),
actions.onRemovePicture,
kind = ButtonKind.Text,
enabled = state.canChangePicture,
)
} else {
KvittButton(
stringResource(DesignR.string.profile_add_picture),
actions.onPickPicture,
kind = ButtonKind.Tonal,
icon = KvittIcon.PhotoLibrary,
enabled = state.canChangePicture,
)
}
if (!state.online) NeedsConnectionHint()
}
KvittTextField(
value = state.name,
onValueChange = actions.onNameChange,
label = stringResource(R.string.onboarding_name_label),
error = when {
state.nameBlank -> stringResource(R.string.onboarding_error_name_required)
state.nameTooLong -> pluralStringResource(DesignR.plurals.error_too_long, PairingUiState.NAME_MAX, PairingUiState.NAME_MAX)
else -> null
},
supporting = if (state.online) null else stringResource(DesignR.string.common_needs_connection),
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Done,
onIme = actions.onContinue,
enabled = state.online && !state.saving,
)
}
}
/**
* The argument of `onboarding_profile_joining`. Swedish forms the genitive as name + "s" in the string itself
* ("Jacobs hushåll"); a name that already ends in s takes no extra s ("Jonas hushåll", strings.csv note), so its final
* s is left to the string.
*/
internal fun genitiveArgument(name: String, language: KvittLanguage): String =
if (language == KvittLanguage.SWEDISH && name.endsWith("s", ignoreCase = true)) name.dropLast(1) else name
private val ProfileStepMessage.text: Int
get() = when (this) {
ProfileStepMessage.PictureTooLarge -> DesignR.string.profile_picture_too_large
ProfileStepMessage.PictureUnsupported -> DesignR.string.profile_picture_unsupported
ProfileStepMessage.PictureInvalid -> DesignR.string.profile_picture_invalid
ProfileStepMessage.PictureFailed -> DesignR.string.profile_picture_failed
ProfileStepMessage.CouldNotReachServer -> DesignR.string.common_could_not_reach_server
ProfileStepMessage.NameRefused -> DesignR.string.error_validation
}
@@ -0,0 +1,152 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.ConnectivityRepository
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
/** A snackbar of S-2 (design-spec S-2 "error"; texts from `:core:design`). */
enum class ProfileStepMessage {
PictureTooLarge,
PictureUnsupported,
PictureInvalid,
PictureFailed,
CouldNotReachServer,
NameRefused,
}
/** The state of S-2. [me] and [owner] are null until the household has loaded. */
data class ProfileStepUiState(
val me: User? = null,
val owner: User? = null,
val name: String = "",
val online: Boolean = true,
val uploading: Boolean = false,
val saving: Boolean = false,
val message: ProfileStepMessage? = null,
val done: Boolean = false,
) {
val nameBlank: Boolean get() = name.isBlank()
val nameTooLong: Boolean get() = name.trim().let { it.codePointCount(0, it.length) } > PairingUiState.NAME_MAX
val hasPicture: Boolean get() = me?.pictureVersion != null
/** "Fortsätt": disabled while the picture uploads and while the name is blank or too long. */
val canContinue: Boolean get() = me != null && !uploading && !saving && !nameBlank && !nameTooLong
/** Picture actions are online only (§9.9, AC-4.3). */
val canChangePicture: Boolean get() = me != null && online && !uploading && !saving
}
/**
* State holder of S-2 Partner profile step (design-spec S-2; AC-2.10, AC-4.3, §9.9): the name prefilled from the
* pairing call, an optional picture uploaded right after it is picked, then Home.
*/
@HiltViewModel
class ProfileStepViewModel @Inject constructor(
session: SessionRepository,
private val profile: ProfileRepository,
connectivity: ConnectivityRepository,
) : ViewModel() {
private val mutable = MutableStateFlow(ProfileStepUiState())
val state: StateFlow<ProfileStepUiState> = mutable.asStateFlow()
init {
viewModelScope.launch {
combine(session.session, profile.household(), connectivity.online) { s, users, online -> Triple(s, users, online) }
.collect { (s, users, online) ->
val meId = (s as? SessionState.Paired)?.me?.id
val me = users.firstOrNull { it.id == meId } ?: (s as? SessionState.Paired)?.me
val owner = users.firstOrNull { it.role == Role.ADMIN && it.id != meId }
?: (s as? SessionState.Paired)?.partner
mutable.update { current ->
current.copy(
me = me,
owner = owner,
// Prefilled once from the name the pairing call stored (AC-2.10).
name = if (current.me == null && me != null) me.displayName else current.name,
online = online,
)
}
}
}
}
fun onNameChange(text: String) = mutable.update { it.copy(name = text) }
/** A picture from the photo picker: upload starts at once (design-spec S-2). */
fun onPicturePicked(bytes: ByteArray, mimeType: String) {
if (!mutable.value.canChangePicture) return
mutable.update { it.copy(uploading = true, message = null) }
viewModelScope.launch {
val result = profile.setPicture(bytes, mimeType)
mutable.update { it.copy(uploading = false, message = pictureMessage(result)) }
}
}
/** The picker returned a file that could not be read. */
fun onPictureUnreadable() = mutable.update { it.copy(message = ProfileStepMessage.PictureInvalid) }
fun onRemovePicture() {
if (!mutable.value.canChangePicture) return
mutable.update { it.copy(uploading = true, message = null) }
viewModelScope.launch {
val result = profile.removePicture()
mutable.update { it.copy(uploading = false, message = pictureMessage(result)) }
}
}
/** "Fortsätt": saves the name when it changed (failure: snackbar, stay), then Home. */
fun onContinue() {
val s = mutable.value
if (!s.canContinue) return
val name = s.name.trim()
if (name == s.me?.displayName) {
mutable.update { it.copy(done = true) }
return
}
mutable.update { it.copy(saving = true, message = null) }
viewModelScope.launch {
val result = profile.setDisplayName(name)
mutable.update {
when (result) {
is OnlineResult.Ok -> it.copy(saving = false, done = true)
OnlineResult.Offline -> it.copy(saving = false, message = ProfileStepMessage.CouldNotReachServer)
is OnlineResult.Failed -> it.copy(
saving = false,
message = if (result.code == "network") ProfileStepMessage.CouldNotReachServer else ProfileStepMessage.NameRefused,
)
}
}
}
}
fun onMessageShown() = mutable.update { it.copy(message = null) }
fun onDoneHandled() = mutable.update { it.copy(done = false) }
}
/** E-10 / E-12 result to S-2's snackbar (design-spec S-2 "error"); null on success. */
internal fun pictureMessage(result: OnlineResult<Unit>): ProfileStepMessage? = when (result) {
is OnlineResult.Ok -> null
OnlineResult.Offline -> ProfileStepMessage.CouldNotReachServer
is OnlineResult.Failed -> when (result.code) {
"payload_too_large" -> ProfileStepMessage.PictureTooLarge
"unsupported_media_type" -> ProfileStepMessage.PictureUnsupported
"image_invalid" -> ProfileStepMessage.PictureInvalid
"network" -> ProfileStepMessage.CouldNotReachServer
else -> ProfileStepMessage.PictureFailed
}
}
@@ -0,0 +1,90 @@
package se.hagstedt.kvitt.feature.onboarding
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.fillMaxSize
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.LaunchedEffect
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.heading
import androidx.compose.ui.semantics.semantics
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.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.icon.KvittIcon
/** S-18 Device removed (design-spec S-18, AC-3.3). [onPairAgain] opens S-1 after the revocation is acknowledged. */
@Composable
fun RemovedScreen(
onPairAgain: () -> Unit,
modifier: Modifier = Modifier,
viewModel: RemovedViewModel = hiltViewModel(),
) {
val acknowledged by viewModel.acknowledged.collectAsStateWithLifecycle()
LaunchedEffect(acknowledged) {
if (acknowledged) {
onPairAgain()
viewModel.onNavigated()
}
}
RemovedContent(onPairAgain = viewModel::onPairAgain, modifier = modifier)
}
/** The stateless S-18, rendered by previews and screenshot tests. */
@Composable
fun RemovedContent(onPairAgain: () -> Unit, modifier: Modifier = Modifier) {
val scheme = MaterialTheme.colorScheme
KvittScaffold(
modifier = modifier,
bottomBar = { BottomActionBar { BottomButton(stringResource(R.string.onboarding_removed_pair_again), onPairAgain, KvittIcon.Link) } },
) { padding ->
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(start = KvittTheme.spacing.gutter, end = KvittTheme.spacing.gutter, top = OnboardingDimens.removedTop),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(OnboardingDimens.columnGap),
) {
Box(
Modifier.size(OnboardingDimens.removedTile).background(scheme.errorContainer, KvittTheme.shapes.illustration),
contentAlignment = Alignment.Center,
) {
KvittIconImage(KvittIcon.PhonelinkErase, null, size = OnboardingDimens.removedIcon, tint = scheme.onErrorContainer)
}
Text(
stringResource(R.string.onboarding_removed_title),
Modifier.semantics { heading() },
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.onboarding_removed_body),
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.onboarding_removed_new_code),
style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
@@ -0,0 +1,31 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.SessionRepository
import javax.inject.Inject
/** State holder of S-18 Device removed (AC-3.3): "Para ihop igen" acknowledges the revocation, then S-1. */
@HiltViewModel
class RemovedViewModel @Inject constructor(private val session: SessionRepository) : ViewModel() {
private val mutable = MutableStateFlow(false)
/** True once the revocation is acknowledged (local data wiped, session `Unpaired`): go to S-1. */
val acknowledged: StateFlow<Boolean> = mutable.asStateFlow()
fun onPairAgain() {
viewModelScope.launch {
session.acknowledgeRevoked()
mutable.value = true
}
}
fun onNavigated() {
mutable.value = false
}
}
@@ -0,0 +1,55 @@
package se.hagstedt.kvitt.feature.onboarding
import java.net.URI
import java.net.URISyntaxException
/** The release server, prefilled on S-1 (AC-2.7, SPEC §10.1). */
const val RELEASE_SERVER_URL = "https://icehack.asuscomm.com:8443"
/** What is wrong with a server address on S-1. */
enum class ServerAddressProblem {
/** Not an `http(s)://host` address: "Para ihop" stays disabled. */
Invalid,
/** `http://` where only `https://` is allowed (AC-2.8): "Adressen måste börja med https://" after the tap. */
HttpsRequired,
}
/**
* The server-address rules of AC-2.8: release builds accept only `https://`; debug builds ([allowLanHttp]) also
* `http://` for `localhost`, `10.0.2.2` and private LAN addresses (`10.*`, `172.16.*`–`172.31.*`, `192.168.*`).
*/
object ServerAddress {
/** The address as sent: trimmed, without a trailing `/`. */
fun normalise(text: String): String = text.trim().trimEnd('/')
/** Null when [text] may be used, else the problem. */
fun check(text: String, allowLanHttp: Boolean): ServerAddressProblem? {
val uri = parse(normalise(text)) ?: return ServerAddressProblem.Invalid
val host = uri.host ?: return ServerAddressProblem.Invalid
return when (uri.scheme?.lowercase()) {
"https" -> null
"http" -> if (allowLanHttp && isLocal(host)) null else ServerAddressProblem.HttpsRequired
else -> ServerAddressProblem.Invalid
}
}
/** True when [text] has the shape of an address at all (scheme http or https and a host). */
fun isWellFormed(text: String): Boolean = check(text, allowLanHttp = true) != ServerAddressProblem.Invalid
private fun parse(text: String): URI? = try {
if (text.isEmpty() || text.any { it.isWhitespace() }) null else URI(text)
} catch (_: URISyntaxException) {
null
}
private fun isLocal(host: String): Boolean {
val h = host.lowercase()
if (h == "localhost" || h == "10.0.2.2") return true
val parts = h.split('.').map { it.toIntOrNull() ?: return false }
if (parts.size != 4 || parts.any { it !in 0..255 }) return false
return parts[0] == 10 ||
(parts[0] == 172 && parts[1] in 16..31) ||
(parts[0] == 192 && parts[1] == 168)
}
}
@@ -0,0 +1,175 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import se.hagstedt.kvitt.core.design.R as DesignR
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.BalanceHero
import se.hagstedt.kvitt.design.component.ButtonSize
import se.hagstedt.kvitt.design.component.HeroState
import se.hagstedt.kvitt.design.component.HeroWords
import se.hagstedt.kvitt.design.component.KvittButton
import se.hagstedt.kvitt.design.component.KvittDestination
import se.hagstedt.kvitt.design.component.KvittIconImage
import se.hagstedt.kvitt.design.component.KvittNavigationBar
import se.hagstedt.kvitt.design.component.KvittScaffold
import se.hagstedt.kvitt.design.component.KvittTopAppBar
import se.hagstedt.kvitt.design.component.LocalKvittClock
import se.hagstedt.kvitt.design.component.NeedsConnectionHint
import se.hagstedt.kvitt.design.component.ProvideHousehold
import se.hagstedt.kvitt.design.component.SegmentedGroup
import se.hagstedt.kvitt.design.component.kvittLanguage
import se.hagstedt.kvitt.design.format.DateFormatter
import se.hagstedt.kvitt.design.icon.KvittIcon
import kotlin.time.Instant
/** What S-3 can be asked to do; the stateless [WaitingContent] calls these. */
data class WaitingActions(
val onInvitePartner: () -> Unit = {},
val onNavigate: (KvittDestination) -> Unit = {},
val onRetry: () -> Unit = {},
val onErrorShown: () -> Unit = {},
)
/**
* S-3 Waiting for partner (design-spec S-3, AC-5.1), shown in the Home destination while the admin is alone.
* [onInvitePartner] opens S-21; [onNavigate] switches bottom-navigation destinations (Settings works, AC-5.1).
*/
@Composable
fun WaitingScreen(
onInvitePartner: () -> Unit,
onNavigate: (KvittDestination) -> Unit,
modifier: Modifier = Modifier,
viewModel: WaitingViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
WaitingContent(
state = state,
actions = WaitingActions(onInvitePartner, onNavigate, viewModel::load, viewModel::onErrorShown),
modifier = modifier,
)
}
/** The stateless S-3, rendered by previews and screenshot tests. */
@Composable
fun WaitingContent(
state: WaitingUiState,
actions: WaitingActions,
modifier: Modifier = Modifier,
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
) {
val error = state.loadError?.let {
stringResource(if (it == "network") DesignR.string.common_could_not_reach_server else DesignR.string.error_server)
}
val retry = stringResource(DesignR.string.common_retry)
LaunchedEffect(error) {
if (error != null) {
val result = snackbarHostState.showSnackbar(error, actionLabel = retry, duration = SnackbarDuration.Long)
actions.onErrorShown()
if (result == SnackbarResult.ActionPerformed) actions.onRetry()
}
}
KvittScaffold(
modifier = modifier,
snackbarHostState = snackbarHostState,
topBar = { KvittTopAppBar(title = null, modifier = Modifier.statusBarsPadding()) },
bottomBar = { KvittNavigationBar(KvittDestination.Home, actions.onNavigate) },
) { padding ->
val me = state.me ?: return@KvittScaffold
ProvideHousehold(me, null) {
Column(
Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = KvittTheme.spacing.gutter),
verticalArrangement = Arrangement.spacedBy(KvittTheme.spacing.sectionGap),
) {
BalanceHero(
state = HeroState.Waiting,
me = me,
words = HeroWords(
settleUp = "",
waitingTitle = stringResource(R.string.onboarding_waiting_title),
waitingBody = stringResource(R.string.onboarding_waiting_body),
waitingPartnerLabel = stringResource(DesignR.string.common_partner),
waitingSeatDescription = stringResource(R.string.onboarding_waiting_partner_missing),
),
onSettle = {},
waitingAction = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
KvittButton(
stringResource(DesignR.string.common_invite_partner),
actions.onInvitePartner,
size = ButtonSize.Medium,
icon = KvittIcon.PersonAdd,
enabled = state.online,
)
if (!state.online) NeedsConnectionHint()
}
},
)
if (state.online && state.inviteExpiresAt != null) PendingInvite(state.inviteExpiresAt)
Text(
stringResource(R.string.onboarding_waiting_footer),
Modifier.padding(horizontal = KvittTheme.spacing.s4),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** The one-item group "Inbjudan skickad" / "Inbjudan gäller till mån 19 okt. 21:40" (design-spec S-3). */
@Composable
private fun PendingInvite(expiresAt: String) {
val clock = LocalKvittClock.current
val expiry = DateFormatter.weekdayDateTime(Instant.parse(expiresAt), clock.zone, clock.today, kvittLanguage())
SegmentedGroup(1) { item ->
Row(
Modifier
.fillMaxWidth()
.heightIn(min = KvittTheme.spacing.rowMin)
.background(item.fill, item.shape)
.semantics(mergeDescendants = true) {}
.padding(horizontal = KvittTheme.spacing.rowHorizontal, vertical = KvittTheme.spacing.rowVertical),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.rowTileGap),
) {
KvittIconImage(KvittIcon.Schedule, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
Column {
Text(stringResource(R.string.onboarding_waiting_invite_sent), style = MaterialTheme.typography.titleSmall)
Text(
stringResource(R.string.onboarding_waiting_invite_expires, expiry),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,76 @@
package se.hagstedt.kvitt.feature.onboarding
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import se.hagstedt.kvitt.model.ConnectivityRepository
import se.hagstedt.kvitt.model.InviteKind
import se.hagstedt.kvitt.model.InviteRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionRepository
import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.model.User
import javax.inject.Inject
/**
* The state of S-3. [inviteExpiresAt] is the RFC 3339 expiry of the pending partner invite, null when there is none
* or it is not known (offline: invites are online only). [loadError]: the §14.3.2 code (or `network`) when the invite
* list could not be fetched.
*/
data class WaitingUiState(
val me: User? = null,
val online: Boolean = true,
val inviteExpiresAt: String? = null,
val loadError: String? = null,
)
/**
* State holder of S-3 Waiting for partner (design-spec S-3; AC-5.1, AC-2.11): the admin alone in the household. The
* pending invite is fetched (E-8) whenever the phone comes online.
*/
@HiltViewModel
class WaitingViewModel @Inject constructor(
private val session: SessionRepository,
private val invites: InviteRepository,
private val connectivity: ConnectivityRepository,
) : ViewModel() {
private val mutable = MutableStateFlow(WaitingUiState())
val state: StateFlow<WaitingUiState> = mutable.asStateFlow()
init {
viewModelScope.launch {
session.session.collect { s -> mutable.update { it.copy(me = (s as? SessionState.Paired)?.me) } }
}
viewModelScope.launch {
connectivity.online.distinctUntilChanged().collect { online ->
mutable.update { it.copy(online = online, inviteExpiresAt = if (online) it.inviteExpiresAt else null, loadError = null) }
if (online) load()
}
}
}
/** Fetches the pending partner invite; "Försök igen" calls it again. */
fun load() {
viewModelScope.launch {
val result = invites.list()
mutable.update { s ->
when (result) {
is OnlineResult.Ok -> s.copy(
inviteExpiresAt = result.value.filter { it.kind == InviteKind.PARTNER }.maxByOrNull { it.expiresAt }?.expiresAt,
loadError = null,
)
OnlineResult.Offline -> s.copy(inviteExpiresAt = null, loadError = null)
is OnlineResult.Failed -> s.copy(inviteExpiresAt = null, loadError = result.code)
}
}
}
}
fun onErrorShown() = mutable.update { it.copy(loadError = null) }
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:onboarding (S-1, S-2, S-3, S-18), from docs/ux/strings.csv (keys onboarding_*, D-017).
OnboardingStringsTest checks every key and text against the CSV; change the CSV first. -->
<resources>
<string name="onboarding_code_paste">Klistra in</string>
<string name="onboarding_name_label">Ditt namn</string>
<string name="onboarding_code_help">Koden får du av %1$s: från en länk eller från Inställningar › Bjud in partner.</string>
<string name="onboarding_code_help_generic">Koden får du från en länk, eller så skrivs den ut när servern sätts upp.</string>
<string name="onboarding_server_label">Serveradress</string>
<string name="onboarding_pair">Para ihop</string>
<string name="onboarding_pairing">Parar ihop…</string>
<string name="onboarding_again_title">Para ihop telefonen igen</string>
<string name="onboarding_again_body">Dina utgifter finns kvar på telefonen.</string>
<string name="onboarding_error_invite_invalid">Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny.</string>
<string name="onboarding_error_network">Kunde inte nå servern. Kontrollera adressen och anslutningen.</string>
<string name="onboarding_error_rate_limited">För många försök. Vänta en stund och försök igen.</string>
<string name="onboarding_error_https">Adressen måste börja med https://</string>
<string name="onboarding_error_code_length">Koden har 12 tecken</string>
<string name="onboarding_error_name_required">Skriv ditt namn</string>
<string name="onboarding_profile_title">Din profil</string>
<string name="onboarding_profile_joining">Du går med i %1$ss hushåll</string>
<string name="onboarding_profile_hint">Så här ser %1$s dig i Kvitt. Du kan ändra det senare under Inställningar › Profil.</string>
<string name="onboarding_waiting_title">Väntar på din partner</string>
<string name="onboarding_waiting_body">Kvitt behöver er båda innan ni kan börja. Bjud in din partner med en länk.</string>
<string name="onboarding_waiting_invite_sent">Inbjudan skickad</string>
<string name="onboarding_waiting_invite_expires">Inbjudan gäller till %1$s</string>
<string name="onboarding_waiting_footer">Konton, utgifter och betalningar blir tillgängliga när din partner har gått med.</string>
<string name="onboarding_waiting_partner_missing">Partner saknas</string>
<string name="onboarding_removed_title">Den här telefonen har tagits bort</string>
<string name="onboarding_removed_body">Åtkomsten togs bort och allt Kvitt-data på telefonen har raderats.</string>
<string name="onboarding_removed_new_code">Vill du använda Kvitt igen behöver du en ny inbjudningskod.</string>
<string name="onboarding_removed_pair_again">Para ihop igen</string>
</resources>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Strings of :feature:onboarding (S-1, S-2, S-3, S-18), from docs/ux/strings.csv (keys onboarding_*, D-017).
OnboardingStringsTest checks every key and text against the CSV; change the CSV first. -->
<resources>
<string name="onboarding_code_paste">Paste</string>
<!-- placeholder of the empty code field -->
<string name="onboarding_code_placeholder" translatable="false">XXXX-XXXX-XXXX</string>
<string name="onboarding_name_label">Your name</string>
<!-- %1$s admin name when known from the link; else use onboarding_code_help_generic -->
<string name="onboarding_code_help">You get the code from %1$s: from a link or from Settings › Invite partner.</string>
<string name="onboarding_code_help_generic">You get the code from a link, or it is printed when the server is set up.</string>
<string name="onboarding_server_label">Server address</string>
<string name="onboarding_pair">Pair</string>
<string name="onboarding_pairing">Pairing…</string>
<!-- §14.4.8 -->
<string name="onboarding_again_title">Pair this phone again</string>
<string name="onboarding_again_body">Your expenses are still on this phone.</string>
<string name="onboarding_error_invite_invalid">This code does not work. It may have expired or already been used. Ask for a new one.</string>
<string name="onboarding_error_network">Could not reach the server. Check the address and your connection.</string>
<string name="onboarding_error_rate_limited">Too many attempts. Wait a while and try again.</string>
<!-- AC-2.8 -->
<string name="onboarding_error_https">The address must start with https://</string>
<string name="onboarding_error_code_length">The code has 12 characters</string>
<string name="onboarding_error_name_required">Enter your name</string>
<string name="onboarding_profile_title">Your profile</string>
<!-- sv genitive: name + s (no apostrophe); names ending in s get no extra s (handled in code: Jonas hushåll) -->
<string name="onboarding_profile_joining">You are joining %1$s\'s household</string>
<!-- %1$s the admin's name -->
<string name="onboarding_profile_hint">This is how %1$s sees you in Kvitt. You can change it later under Settings › Profile.</string>
<string name="onboarding_waiting_title">Waiting for your partner</string>
<string name="onboarding_waiting_body">Kvitt needs both of you before you can start. Invite your partner with a link.</string>
<!-- pending invite row title -->
<string name="onboarding_waiting_invite_sent">Invite sent</string>
<!-- %1$s weekday + date + time, e.g. mån 19 okt. 21:40 -->
<string name="onboarding_waiting_invite_expires">Invite valid until %1$s</string>
<string name="onboarding_waiting_footer">Accounts, expenses and payments become available when your partner has joined.</string>
<!-- TalkBack of the empty seat -->
<string name="onboarding_waiting_partner_missing">Partner has not joined</string>
<string name="onboarding_removed_title">This phone has been removed</string>
<!-- AC-3.3 -->
<string name="onboarding_removed_body">Access was removed and all Kvitt data on the phone has been deleted.</string>
<string name="onboarding_removed_new_code">To use Kvitt again you need a new invite code.</string>
<string name="onboarding_removed_pair_again">Pair again</string>
</resources>
@@ -0,0 +1,38 @@
package se.hagstedt.kvitt.feature.onboarding
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** "Klistra in" (design-spec S-1) and the code display and normalisation of §8.3. */
class InviteLinkTest {
@Test
fun aBareCodeInAnySpellingIsNormalised() {
assertEquals(PastedInvite("7K3MQX9D2HPA", null), InviteLink.parse("7K3M-QX9D-2HPA"))
assertEquals(PastedInvite("7K3MQX9D2HPA", null), InviteLink.parse(" 7k3m qx9d 2hpa\n"))
// O becomes 0, I and L become 1 (§8.3).
assertEquals(PastedInvite("0110ABCDEFGH", null), InviteLink.parse("OIlo-abcd-efgh"))
}
@Test
fun codesOfTheWrongLengthOrWithForeignCharactersAreRefused() {
assertNull(InviteLink.parse("7K3M-QX9D-2HP"))
assertNull(InviteLink.parse("7K3M-QX9D-2HPAX"))
assertNull(InviteLink.parse("7K3M-QX9D-2HPU"))
assertNull(InviteLink.parse("hello"))
}
@Test
fun theAppLinkGivesCodeAndServer() {
val parsed = InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA")
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), parsed)
assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA"))
}
@Test
fun theLandingLinkGivesCodeAndServer() {
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA"))
assertEquals(PastedInvite("7K3MQX9D2HPA", "http://192.168.1.210:8080"), InviteLink.parse("http://192.168.1.210:8080/i/7k3mqx9d2hpa"))
assertNull(InviteLink.parse("https://icehack.asuscomm.com:8443/x/7K3MQX9D2HPA"))
}
}
@@ -0,0 +1,60 @@
package se.hagstedt.kvitt.feature.onboarding
import org.junit.Assert.assertEquals
import org.junit.Test
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
/** The module's strings are exactly the `onboarding_*` rows of docs/ux/strings.csv, in English and Swedish (D-017). */
class OnboardingStringsTest {
private data class Row(val key: String, val en: String, val sv: String, val notes: String)
private fun csv(): List<Row> {
val text = File("../../../docs/ux/strings.csv").readText()
val rows = mutableListOf<List<String>>()
var field = StringBuilder()
var record = mutableListOf<String>()
var quoted = false
var i = 0
while (i < text.length) {
val c = text[i]
when {
quoted && c == '"' && i + 1 < text.length && text[i + 1] == '"' -> { field.append('"'); i++ }
c == '"' -> quoted = !quoted
!quoted && c == ',' -> { record.add(field.toString()); field = StringBuilder() }
!quoted && c == '\n' -> { record.add(field.toString()); rows.add(record); record = mutableListOf(); field = StringBuilder() }
c == '\r' -> Unit
else -> field.append(c)
}
i++
}
if (field.isNotEmpty() || record.isNotEmpty()) { record.add(field.toString()); rows.add(record) }
return rows.drop(1).filter { it.size >= 4 && it[0].startsWith("onboarding_") }.map { Row(it[0], it[1], it[2], it[3]) }
}
private fun resources(folder: String): Map<String, String> {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File("src/main/res/$folder/strings.xml"))
val strings = doc.getElementsByTagName("string")
return (0 until strings.length).associate { i ->
val e = strings.item(i) as Element
e.getAttribute("name") to e.textContent.replace("\\'", "'").replace("\\\"", "\"").replace("\\\\", "\\")
}
}
@Test
fun englishMatchesTheCsv() {
val rows = csv()
val en = resources("values")
assertEquals(rows.map { it.key }.toSet(), en.keys)
rows.forEach { assertEquals(it.key, it.en, en[it.key]) }
}
@Test
fun swedishMatchesTheCsv() {
val rows = csv().filter { "translatable=false" !in it.notes }
val sv = resources("values-sv")
assertEquals(rows.map { it.key }.toSet(), sv.keys)
rows.forEach { assertEquals(it.key, it.sv, sv[it.key]) }
}
}
@@ -0,0 +1,161 @@
package se.hagstedt.kvitt.feature.onboarding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.design.format.KvittLanguage
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
import se.hagstedt.kvitt.fakes.onboarding.ScriptedInviteRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedProfileRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
import se.hagstedt.kvitt.model.OnlineResult
import se.hagstedt.kvitt.model.SessionState
/** S-2, S-3 and S-18 state holders over the onboarding fake scenarios. */
@OptIn(ExperimentalCoroutinesApi::class)
class OtherViewModelsTest {
@Before
fun main() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun reset() = Dispatchers.resetMain()
private fun profile(scenario: FakeScenario, picture: CallOutcome = CallOutcome.Succeed, name: CallOutcome = CallOutcome.Succeed) =
FakeRepositories(scenario).let { f ->
val repo = ScriptedProfileRepository(f.store, picture, name)
repo to ProfileStepViewModel(f.session, repo, f.connectivity)
}
@Test
fun theProfileStepPrefillsTheNameAndNamesTheOwner() {
val (_, vm) = profile(OnboardingScenarios.partnerJustPaired)
val s = vm.state.value
assertEquals("Anna", s.name)
assertEquals("Jacob", s.owner?.displayName)
assertTrue(s.canContinue)
assertFalse(s.hasPicture)
}
@Test
fun anUploadDisablesContinueUntilItEndsAndFailuresGiveTheirReason() {
val (_, hanging) = profile(OnboardingScenarios.partnerJustPaired, picture = CallOutcome.Hang)
hanging.onPicturePicked(ByteArray(4), "image/jpeg")
assertTrue(hanging.state.value.uploading)
assertFalse(hanging.state.value.canContinue)
val (_, ok) = profile(OnboardingScenarios.partnerJustPaired)
ok.onPicturePicked(ByteArray(4), "image/jpeg")
assertTrue(ok.state.value.hasPicture)
assertNull(ok.state.value.message)
mapOf(
"payload_too_large" to ProfileStepMessage.PictureTooLarge,
"unsupported_media_type" to ProfileStepMessage.PictureUnsupported,
"image_invalid" to ProfileStepMessage.PictureInvalid,
"network" to ProfileStepMessage.CouldNotReachServer,
"internal" to ProfileStepMessage.PictureFailed,
).forEach { (code, expected) ->
val (_, vm) = profile(OnboardingScenarios.partnerJustPaired, picture = CallOutcome.Fail(code))
vm.onPicturePicked(ByteArray(4), "image/jpeg")
assertEquals(code, expected, vm.state.value.message)
assertFalse(vm.state.value.uploading)
assertTrue(vm.state.value.canContinue)
assertFalse(vm.state.value.hasPicture)
}
}
@Test
fun offlineThePictureCannotChange() {
val (_, vm) = profile(OnboardingScenarios.partnerOffline)
assertFalse(vm.state.value.canChangePicture)
vm.onPicturePicked(ByteArray(4), "image/jpeg")
assertFalse(vm.state.value.uploading)
assertTrue(vm.state.value.canContinue) // an unchanged name needs no call
}
@Test
fun continueSavesAChangedNameAndStaysOnFailure() {
val (repo, vm) = profile(OnboardingScenarios.partnerJustPaired)
vm.onContinue()
assertTrue(vm.state.value.done)
assertTrue(repo.namesSet.isEmpty())
val (changedRepo, changed) = profile(OnboardingScenarios.partnerJustPaired)
changed.onNameChange(" Anna-Karin ")
changed.onContinue()
assertEquals(listOf("Anna-Karin"), changedRepo.namesSet)
assertTrue(changed.state.value.done)
val (_, failing) = profile(OnboardingScenarios.partnerJustPaired, name = CallOutcome.Fail("network"))
failing.onNameChange("Anna-Karin")
failing.onContinue()
assertFalse(failing.state.value.done)
assertEquals(ProfileStepMessage.CouldNotReachServer, failing.state.value.message)
val (_, blank) = profile(OnboardingScenarios.partnerJustPaired)
blank.onNameChange(" ")
assertFalse(blank.state.value.canContinue)
}
@Test
fun pictureMessagesMapOnlineResults() {
assertNull(pictureMessage(OnlineResult.Ok(Unit)))
assertEquals(ProfileStepMessage.CouldNotReachServer, pictureMessage(OnlineResult.Offline))
}
@Test
fun swedishGenitiveAddsNoSecondS() {
assertEquals("Jacob", genitiveArgument("Jacob", KvittLanguage.SWEDISH))
assertEquals("Jona", genitiveArgument("Jonas", KvittLanguage.SWEDISH))
assertEquals("Jonas", genitiveArgument("Jonas", KvittLanguage.ENGLISH))
}
private fun waiting(scenario: FakeScenario, list: CallOutcome = CallOutcome.Succeed) = FakeRepositories(scenario).let { f ->
WaitingViewModel(f.session, ScriptedInviteRepository(f.store, list), f.connectivity) to f
}
@Test
fun waitingShowsThePendingInviteExpiryOnlyOnline() {
assertEquals("2026-10-19T19:40:00.000Z", waiting(OnboardingScenarios.waitingWithInvite).first.state.value.inviteExpiresAt)
assertNull(waiting(OnboardingScenarios.waitingWithoutInvite).first.state.value.inviteExpiresAt)
val offline = waiting(OnboardingScenarios.waitingOffline).first.state.value
assertNull(offline.inviteExpiresAt)
assertFalse(offline.online)
assertNull(offline.loadError)
}
@Test
fun aFailedInviteListIsAnErrorAndComingOnlineLoadsAgain() {
val (vm, _) = waiting(OnboardingScenarios.waitingWithInvite, CallOutcome.Fail("network"))
assertEquals("network", vm.state.value.loadError)
assertNull(vm.state.value.inviteExpiresAt)
val (later, fakes) = waiting(OnboardingScenarios.waitingOffline)
fakes.store.edit { it.copy(online = true) }
assertEquals("2026-10-19T19:40:00.000Z", later.state.value.inviteExpiresAt)
}
@Test
fun pairAgainAcknowledgesTheRevocationFirst() {
val f = FakeRepositories(OnboardingScenarios.revoked)
val session = ScriptedSessionRepository(f.store)
val vm = RemovedViewModel(session)
assertFalse(vm.acknowledged.value)
vm.onPairAgain()
assertEquals(1, session.acknowledged)
assertEquals(SessionState.Unpaired, f.store.current.session)
assertTrue(vm.acknowledged.value)
}
}
@@ -0,0 +1,176 @@
package se.hagstedt.kvitt.feature.onboarding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
import se.hagstedt.kvitt.fakes.onboarding.PairCall
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
@OptIn(ExperimentalCoroutinesApi::class)
class PairingViewModelTest {
@Before
fun main() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun reset() = Dispatchers.resetMain()
private fun vm(scenario: FakeScenario = OnboardingScenarios.unpaired, outcome: CallOutcome = CallOutcome.Succeed, allowLanHttp: Boolean = false) =
ScriptedSessionRepository(FakeRepositories(scenario).store, outcome).let { it to PairingViewModel(it, allowLanHttp) }
@Test
fun theCodeIsNormalisedWhileTyping() {
val (_, vm) = vm()
vm.onCodeChange("7k3m-qx9d-2hpa-extra")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
vm.onCodeChange("oil")
assertEquals("011", vm.state.value.code)
}
@Test
fun pairIsEnabledWithTwelveCharactersANameAndAWellFormedAddress() {
val (_, vm) = vm()
assertEquals(RELEASE_SERVER_URL, vm.state.value.serverUrl)
vm.onCodeChange("7K3MQX9D2HP")
vm.onNameChange("Anna")
assertFalse(vm.state.value.canPair)
vm.onCodeChange("7K3MQX9D2HPA")
assertTrue(vm.state.value.canPair)
vm.onNameChange(" ")
assertFalse(vm.state.value.canPair)
vm.onNameChange("A".repeat(41))
assertTrue(vm.state.value.nameTooLong)
assertFalse(vm.state.value.canPair)
vm.onNameChange("Anna")
vm.onServerChange("not an address")
assertFalse(vm.state.value.canPair)
}
@Test
fun aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName() {
val (session, vm) = vm(OnboardingScenarios.unpairedPartner)
vm.prefill(RELEASE_SERVER_URL + "/", "7k3m-qx9d-2hpa")
vm.onNameChange(" Anna ")
vm.pair("Galaxy S26+")
assertEquals(listOf(PairCall(RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Anna", "Galaxy S26+")), session.pairCalls)
assertEquals(PairingDone.ProfileStep, vm.state.value.done)
}
@Test
fun theAdminGoesHomeAndTheAgainVariantSendsNoName() {
val (_, admin) = vm(OnboardingScenarios.unpaired)
admin.prefill(null, "7K3MQX9D2HPA")
admin.onNameChange("Jacob")
admin.pair("Galaxy S26")
assertEquals(PairingDone.Home, admin.state.value.done)
val (session, again) = vm(OnboardingScenarios.unrecognised)
assertEquals(PairingMode.Again, again.state.value.mode)
again.onCodeChange("7K3MQX9D2HPA")
assertTrue(again.state.value.canPair)
again.pair("Galaxy S26")
assertNull(session.pairCalls.single().displayName)
assertEquals(PairingDone.Home, again.state.value.done)
}
@Test
fun aLinkWithAnotherServerOpensTheServerRowOnce() {
val (_, vm) = vm()
vm.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
assertTrue(vm.state.value.serverExpanded)
vm.prefill(RELEASE_SERVER_URL, "AAAAAAAAAAAA")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
val (_, plain) = vm()
plain.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA")
assertFalse(plain.state.value.serverExpanded)
}
@Test
fun errorsMapToTheirFields() {
mapOf(
"invite_invalid" to PairingError.InviteInvalid,
"rate_limited" to PairingError.RateLimited,
"network" to PairingError.Network,
"protocol_unsupported" to PairingError.AppTooOld,
"internal" to PairingError.Server,
"unavailable" to PairingError.Server,
"validation_failed" to PairingError.Validation,
).forEach { (code, expected) ->
val (_, vm) = vm(outcome = CallOutcome.Fail(code))
vm.prefill(null, "7K3MQX9D2HPA")
vm.onNameChange("Anna")
vm.pair("Galaxy S26")
assertEquals(code, expected, vm.state.value.error)
assertFalse(vm.state.value.pairing)
assertTrue(vm.state.value.canPair) // the button is enabled again
}
}
@Test
fun offlinePairingGivesTheNetworkErrorUnderTheExpandedServer() {
val (_, vm) = vm(OnboardingScenarios.unpairedOffline)
vm.prefill(null, "7K3MQX9D2HPA")
vm.onNameChange("Anna")
vm.pair("Galaxy S26")
assertEquals(PairingError.Network, vm.state.value.error)
assertTrue(vm.state.value.serverExpanded)
}
@Test
fun releaseRefusesHttpBeforeCallingTheServerAndDebugAllowsTheLan() {
val (session, release) = vm()
release.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
release.onNameChange("Anna")
release.pair("Galaxy S26")
assertEquals(PairingError.HttpsRequired, release.state.value.error)
assertTrue(session.pairCalls.isEmpty())
val (debugSession, debug) = vm(allowLanHttp = true)
debug.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
debug.onNameChange("Anna")
debug.pair("Galaxy S26")
assertNull(debug.state.value.error)
assertEquals("http://192.168.1.210:8080", debugSession.pairCalls.single().serverUrl)
}
@Test
fun whilePairingTheButtonIsBusyAndEditingClearsTheFieldError() {
val (_, hanging) = vm(outcome = CallOutcome.Hang)
hanging.prefill(null, "7K3MQX9D2HPA")
hanging.onNameChange("Anna")
hanging.pair("Galaxy S26")
assertTrue(hanging.state.value.pairing)
assertFalse(hanging.state.value.canPair)
val (_, failed) = vm(outcome = CallOutcome.Fail("invite_invalid"))
failed.prefill(null, "7K3MQX9D2HPA")
failed.onNameChange("Anna")
failed.pair("Galaxy S26")
failed.onCodeChange("7K3MQX9D2HPB")
assertNull(failed.state.value.error)
}
@Test
fun pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters() {
val (_, vm) = vm()
vm.onPaste("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
assertNull(vm.state.value.error)
vm.onPaste("hello")
assertEquals(PairingError.CodeLength, vm.state.value.error)
vm.onPaste(null)
assertEquals(PairingError.CodeLength, vm.state.value.error)
}
}
@@ -0,0 +1,48 @@
package se.hagstedt.kvitt.feature.onboarding
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** AC-2.8: release accepts only https://; debug also http:// for localhost, the emulator host and private LANs. */
class ServerAddressTest {
@Test
fun httpsIsAlwaysAccepted() {
assertNull(ServerAddress.check(RELEASE_SERVER_URL, allowLanHttp = false))
assertNull(ServerAddress.check("https://example.org/", allowLanHttp = false))
}
@Test
fun releaseRefusesEveryHttpAddress() {
listOf("http://192.168.1.210:8080", "http://localhost:8080", "http://10.0.2.2:8080", "http://example.org").forEach {
assertEquals(it, ServerAddressProblem.HttpsRequired, ServerAddress.check(it, allowLanHttp = false))
}
}
@Test
fun debugAcceptsHttpOnlyForLocalAndPrivateAddresses() {
listOf(
"http://localhost:8080", "http://10.0.2.2:8080", "http://10.1.2.3", "http://172.16.0.1",
"http://172.31.255.255:8080", "http://192.168.1.210:8080",
).forEach { assertNull(it, ServerAddress.check(it, allowLanHttp = true)) }
listOf("http://172.15.0.1", "http://172.32.0.1", "http://192.169.0.1", "http://example.org", "http://8.8.8.8").forEach {
assertEquals(it, ServerAddressProblem.HttpsRequired, ServerAddress.check(it, allowLanHttp = true))
}
}
@Test
fun malformedAddressesAreInvalid() {
listOf("", "icehack.asuscomm.com", "ftp://example.org", "https://", "https://exa mple.org", "kvitt://invite").forEach {
assertEquals(it, ServerAddressProblem.Invalid, ServerAddress.check(it, allowLanHttp = true))
assertFalse(it, ServerAddress.isWellFormed(it))
}
assertTrue(ServerAddress.isWellFormed("http://example.org"))
}
@Test
fun normaliseTrimsSpacesAndTrailingSlash() {
assertEquals(RELEASE_SERVER_URL, ServerAddress.normalise(" $RELEASE_SERVER_URL/ "))
}
}
@@ -0,0 +1,49 @@
package se.hagstedt.kvitt.feature.onboarding.screenshot
import androidx.compose.ui.test.junit4.v2.createComposeRule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* One golden per screen state × light/dark × Swedish/English (PLAN-M7a §5, §7), plus 200 % for the states marked so.
* `check` verifies them; re-record an intended change with
* `tools/gradle-serial.sh :feature:onboarding:recordRoborazziDebug` and open every changed PNG.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(ParameterizedRobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi")
class OnboardingScreenshotTest(private val name: String) {
@get:Rule
val compose = createComposeRule()
@Before
fun mainDispatcher() = Dispatchers.setMain(UnconfinedTestDispatcher())
@After
fun resetMainDispatcher() = Dispatchers.resetMain()
@Test
fun screen() {
val state = OnboardingStates.all.first { it.name == name }
val content = state.render()
ScreenSnapshots.snap(compose, state.name, state.largeFont, content)
}
companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
fun states(): List<Array<Any>> = OnboardingStates.all.map { arrayOf<Any>(it.name) }
}
}
@@ -0,0 +1,116 @@
package se.hagstedt.kvitt.feature.onboarding.screenshot
import androidx.compose.runtime.Composable
import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
import se.hagstedt.kvitt.fakes.onboarding.ScriptedInviteRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedProfileRepository
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
import se.hagstedt.kvitt.feature.onboarding.PairingActions
import se.hagstedt.kvitt.feature.onboarding.PairingContent
import se.hagstedt.kvitt.feature.onboarding.PairingViewModel
import se.hagstedt.kvitt.feature.onboarding.ProfileStepActions
import se.hagstedt.kvitt.feature.onboarding.ProfileStepContent
import se.hagstedt.kvitt.feature.onboarding.ProfileStepViewModel
import se.hagstedt.kvitt.feature.onboarding.RELEASE_SERVER_URL
import se.hagstedt.kvitt.feature.onboarding.RemovedContent
import se.hagstedt.kvitt.feature.onboarding.WaitingActions
import se.hagstedt.kvitt.feature.onboarding.WaitingContent
import se.hagstedt.kvitt.feature.onboarding.WaitingViewModel
/** One screen state: the golden's base name, whether a 200 % capture is added, and how to render it. */
class ScreenState(val name: String, val largeFont: Boolean = false, val render: () -> @Composable () -> Unit)
private const val CODE = "7K3MQX9D2HPA"
private const val LAN_SERVER = "http://192.168.1.210:8080"
/**
* Every state of S-1, S-2, S-3 and S-18 (design-spec §4 and PLAN-M7a §5). Each is produced by the screen's real
* ViewModel over a `:core:fakes` scenario (with a scripted outcome where a scenario cannot express it); the captured
* UI state is then rendered by the stateless screen. `Dispatchers.Main` is a test dispatcher (see the test class).
*/
object OnboardingStates {
private fun pairing(
scenario: FakeScenario,
outcome: CallOutcome = CallOutcome.Succeed,
allowLanHttp: Boolean = false,
act: PairingViewModel.() -> Unit,
): () -> @Composable () -> Unit = {
val store = FakeRepositories(scenario).store
val vm = PairingViewModel(ScriptedSessionRepository(store, outcome), allowLanHttp)
vm.act()
val state = vm.state.value
{ PairingContent(state, PairingActions()) }
}
private fun profile(
scenario: FakeScenario,
pictureOutcome: CallOutcome = CallOutcome.Succeed,
act: ProfileStepViewModel.() -> Unit = {},
): () -> @Composable () -> Unit = {
val fakes = FakeRepositories(scenario)
val vm = ProfileStepViewModel(fakes.session, ScriptedProfileRepository(fakes.store, pictureOutcome), fakes.connectivity)
vm.act()
val state = vm.state.value
{ ProfileStepContent(state, ProfileStepActions()) }
}
private fun waiting(scenario: FakeScenario, listOutcome: CallOutcome = CallOutcome.Succeed): () -> @Composable () -> Unit = {
val fakes = FakeRepositories(scenario)
val vm = WaitingViewModel(fakes.session, ScriptedInviteRepository(fakes.store, listOutcome), fakes.connectivity)
val state = vm.state.value
{ WaitingContent(state, WaitingActions()) }
}
private fun PairingViewModel.fillFromLink(server: String = RELEASE_SERVER_URL) {
prefill(server, CODE)
onNameChange("Anna")
}
private val picture = ByteArray(16)
val all: List<ScreenState> = listOf(
// S-1 Pairing.
ScreenState("s1_pairing_empty", render = pairing(OnboardingScenarios.unpaired) {}),
ScreenState("s1_pairing", largeFont = true, render = pairing(OnboardingScenarios.unpaired) { fillFromLink() }),
ScreenState("s1_pairing_link_lan", render = pairing(OnboardingScenarios.unpaired, allowLanHttp = true) { fillFromLink(LAN_SERVER) }),
ScreenState("s1_pairing_loading", render = pairing(OnboardingScenarios.unpaired, CallOutcome.Hang) { fillFromLink(); pair("Galaxy S26") }),
ScreenState(
"s1_pairing_error",
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("invite_invalid")) { fillFromLink(); pair("Galaxy S26") },
),
ScreenState(
"s1_pairing_error_network",
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("network")) { fillFromLink(); pair("Galaxy S26") },
),
ScreenState(
"s1_pairing_error_rate_limited",
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("rate_limited")) { fillFromLink(); pair("Galaxy S26") },
),
ScreenState("s1_pairing_error_https", render = pairing(OnboardingScenarios.unpaired) { fillFromLink(LAN_SERVER); pair("Galaxy S26") }),
ScreenState("s1_pairing_offline", render = pairing(OnboardingScenarios.unpairedOffline) { fillFromLink(); pair("Galaxy S26") }),
ScreenState("s1_pairing_again", render = pairing(OnboardingScenarios.unrecognised) {}),
// S-2 Partner profile step.
ScreenState("s2_profile_step", render = profile(OnboardingScenarios.partnerJustPaired)),
ScreenState("s2_profile_step_picture", render = profile(OnboardingScenarios.partnerWithPicture)),
ScreenState(
"s2_profile_step_loading",
render = profile(OnboardingScenarios.partnerJustPaired, CallOutcome.Hang) { onPicturePicked(picture, "image/jpeg") },
),
ScreenState(
"s2_profile_step_error",
render = profile(OnboardingScenarios.partnerJustPaired, CallOutcome.Fail("payload_too_large")) { onPicturePicked(picture, "image/jpeg") },
),
ScreenState("s2_profile_step_offline", render = profile(OnboardingScenarios.partnerOffline)),
ScreenState("s2_profile_step_long", largeFont = true, render = profile(OnboardingScenarios.partnerLongName)),
// S-3 Waiting for partner.
ScreenState("s3_waiting", render = waiting(OnboardingScenarios.waitingWithInvite)),
ScreenState("s3_waiting_no_invite", render = waiting(OnboardingScenarios.waitingWithoutInvite)),
ScreenState("s3_waiting_offline", render = waiting(OnboardingScenarios.waitingOffline)),
ScreenState("s3_waiting_error", render = waiting(OnboardingScenarios.waitingWithInvite, CallOutcome.Fail("network"))),
// S-18 Device removed.
ScreenState("s18_removed", render = { { RemovedContent(onPairAgain = {}) } }),
)
}
@@ -0,0 +1,76 @@
package se.hagstedt.kvitt.feature.onboarding.screenshot
import android.content.res.Configuration
import android.os.LocaleList
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.test.junit4.ComposeContentTestRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.Density
import com.github.takahirom.roborazzi.captureRoboImage
import kotlinx.datetime.TimeZone
import se.hagstedt.kvitt.design.KvittTheme
import se.hagstedt.kvitt.design.component.KvittClock
import se.hagstedt.kvitt.design.component.LocalKvittClock
import java.util.Locale
import kotlin.time.Instant
/**
* Captures a whole screen once per theme and language: `src/test/screenshots/<name>_<sv|en>_<light|dark>.png`, plus
* `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish, light), as `:core:design`'s Snapshots
* do for components. The phone is the Galaxy S26 (411 dp wide, SPEC §13.1); the clock is fixed at 17 October 2026
* 14:02 in Stockholm so every render is reproducible.
*/
object ScreenSnapshots {
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
}
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
var current by mutableStateOf(variants.first())
compose.setContent {
val variant = current
val base = LocalContext.current
val baseConfiguration = LocalConfiguration.current
val context = remember(variant) {
val config = Configuration(baseConfiguration).apply {
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
fontScale = variant.fontScale
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
}
base.createConfigurationContext(config)
}
CompositionLocalProvider(
LocalContext provides context,
LocalConfiguration provides context.resources.configuration,
LocalResources provides context.resources,
LocalDensity provides Density(context),
LocalKvittClock provides clock,
) {
KvittTheme(darkTheme = variant.dark) {
Box(Modifier.fillMaxSize()) { content() }
}
}
}
variants.forEach { variant ->
current = variant
compose.waitForIdle()
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
}
}
}