diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/OnboardingScenarios.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/OnboardingScenarios.kt new file mode 100644 index 0000000..1bb741f --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/OnboardingScenarios.kt @@ -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)) +} diff --git a/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/ScriptedRepositories.kt b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/ScriptedRepositories.kt new file mode 100644 index 0000000..7689e49 --- /dev/null +++ b/android/core/fakes/src/main/kotlin/se/hagstedt/kvitt/fakes/onboarding/ScriptedRepositories.kt @@ -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 FakeStore.scripted(outcome: CallOutcome, succeed: suspend () -> OnlineResult): OnlineResult = 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() + var acknowledged = 0 + private set + + override val session: Flow = delegate.session + + override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult { + 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() + + override fun household(): Flow> = delegate.household() + + override suspend fun setDisplayName(name: String): OnlineResult { + namesSet += name + return store.scripted(nameOutcome) { delegate.setDisplayName(name) } + } + + override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult = + store.scripted(pictureOutcome) { delegate.setPicture(bytes, mimeType) } + + override suspend fun removePicture(): OnlineResult = 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> = store.scripted(listOutcome) { delegate.list() } + + override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult = delegate.create(kind, targetUser) + + override suspend fun cancel(id: InviteId): OnlineResult = delegate.cancel(id) +} diff --git a/android/feature/onboarding/build.gradle.kts b/android/feature/onboarding/build.gradle.kts index 49aac00..34b05f5 100644 --- a/android/feature/onboarding/build.gradle.kts +++ b/android/feature/onboarding/build.gradle.kts @@ -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) } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt new file mode 100644 index 0000000..f556b54 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLink.kt @@ -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:///i/` (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 } + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingDimens.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingDimens.kt new file mode 100644 index 0000000..1330db6 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingDimens.kt @@ -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 +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingEntry.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingEntry.kt deleted file mode 100644 index d780f5a..0000000 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingEntry.kt +++ /dev/null @@ -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) -} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingParts.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingParts.kt new file mode 100644 index 0000000..4d903b7 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingParts.kt @@ -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) + } + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt new file mode 100644 index 0000000..61003a6 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingScreen.kt @@ -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) + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt new file mode 100644 index 0000000..8997086 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt @@ -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 = 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 diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt new file mode 100644 index 0000000..85f9fb8 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepScreen.kt @@ -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 + } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepViewModel.kt new file mode 100644 index 0000000..5873c80 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ProfileStepViewModel.kt @@ -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 = 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): 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 + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedScreen.kt new file mode 100644 index 0000000..7f8533c --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedScreen.kt @@ -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, + ) + } + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedViewModel.kt new file mode 100644 index 0000000..abf3b3d --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/RemovedViewModel.kt @@ -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 = mutable.asStateFlow() + + fun onPairAgain() { + viewModelScope.launch { + session.acknowledgeRevoked() + mutable.value = true + } + } + + fun onNavigated() { + mutable.value = false + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddress.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddress.kt new file mode 100644 index 0000000..6096944 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddress.kt @@ -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) + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt new file mode 100644 index 0000000..f9a7fa4 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingScreen.kt @@ -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, + ) + } + } + } +} diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingViewModel.kt new file mode 100644 index 0000000..ab5aad9 --- /dev/null +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/WaitingViewModel.kt @@ -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 = 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) } +} diff --git a/android/feature/onboarding/src/main/res/values-sv/strings.xml b/android/feature/onboarding/src/main/res/values-sv/strings.xml new file mode 100644 index 0000000..1cf41e3 --- /dev/null +++ b/android/feature/onboarding/src/main/res/values-sv/strings.xml @@ -0,0 +1,33 @@ + + + + Klistra in + Ditt namn + Koden får du av %1$s: från en länk eller från Inställningar › Bjud in partner. + Koden får du från en länk, eller så skrivs den ut när servern sätts upp. + Serveradress + Para ihop + Parar ihop… + Para ihop telefonen igen + Dina utgifter finns kvar på telefonen. + Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny. + Kunde inte nå servern. Kontrollera adressen och anslutningen. + För många försök. Vänta en stund och försök igen. + Adressen måste börja med https:// + Koden har 12 tecken + Skriv ditt namn + Din profil + Du går med i %1$ss hushåll + Så här ser %1$s dig i Kvitt. Du kan ändra det senare under Inställningar › Profil. + Väntar på din partner + Kvitt behöver er båda innan ni kan börja. Bjud in din partner med en länk. + Inbjudan skickad + Inbjudan gäller till %1$s + Konton, utgifter och betalningar blir tillgängliga när din partner har gått med. + Partner saknas + Den här telefonen har tagits bort + Åtkomsten togs bort och allt Kvitt-data på telefonen har raderats. + Vill du använda Kvitt igen behöver du en ny inbjudningskod. + Para ihop igen + diff --git a/android/feature/onboarding/src/main/res/values/strings.xml b/android/feature/onboarding/src/main/res/values/strings.xml new file mode 100644 index 0000000..4c7f890 --- /dev/null +++ b/android/feature/onboarding/src/main/res/values/strings.xml @@ -0,0 +1,44 @@ + + + + Paste + + XXXX-XXXX-XXXX + Your name + + You get the code from %1$s: from a link or from Settings › Invite partner. + You get the code from a link, or it is printed when the server is set up. + Server address + Pair + Pairing… + + Pair this phone again + Your expenses are still on this phone. + This code does not work. It may have expired or already been used. Ask for a new one. + Could not reach the server. Check the address and your connection. + Too many attempts. Wait a while and try again. + + The address must start with https:// + The code has 12 characters + Enter your name + Your profile + + You are joining %1$s\'s household + + This is how %1$s sees you in Kvitt. You can change it later under Settings › Profile. + Waiting for your partner + Kvitt needs both of you before you can start. Invite your partner with a link. + + Invite sent + + Invite valid until %1$s + Accounts, expenses and payments become available when your partner has joined. + + Partner has not joined + This phone has been removed + + Access was removed and all Kvitt data on the phone has been deleted. + To use Kvitt again you need a new invite code. + Pair again + diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt new file mode 100644 index 0000000..39b73b1 --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt @@ -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")) + } +} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingStringsTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingStringsTest.kt new file mode 100644 index 0000000..70b3859 --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OnboardingStringsTest.kt @@ -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 { + val text = File("../../../docs/ux/strings.csv").readText() + val rows = mutableListOf>() + var field = StringBuilder() + var record = mutableListOf() + 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 { + 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]) } + } +} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OtherViewModelsTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OtherViewModelsTest.kt new file mode 100644 index 0000000..1c9de6f --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/OtherViewModelsTest.kt @@ -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) + } +} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt new file mode 100644 index 0000000..78af585 --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt @@ -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) + } +} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddressTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddressTest.kt new file mode 100644 index 0000000..0c21d84 --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddressTest.kt @@ -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/ ")) + } +} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingScreenshotTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingScreenshotTest.kt new file mode 100644 index 0000000..7f9b45a --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingScreenshotTest.kt @@ -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> = OnboardingStates.all.map { arrayOf(it.name) } + } +} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt new file mode 100644 index 0000000..143d234 --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt @@ -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 = 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 = {}) } }), + ) +} diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/ScreenSnapshots.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/ScreenSnapshots.kt new file mode 100644 index 0000000..a54e53a --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/ScreenSnapshots.kt @@ -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/__.png`, plus + * `_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") + } + } +}