M7a: feature onboarding (S-1, S-2, S-3, S-18) with fakes, unit and screenshot tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+80
@@ -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))
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package se.hagstedt.kvitt.fakes.onboarding
|
||||
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import se.hagstedt.kvitt.fakes.FakeInviteRepository
|
||||
import se.hagstedt.kvitt.fakes.FakeProfileRepository
|
||||
import se.hagstedt.kvitt.fakes.FakeSessionRepository
|
||||
import se.hagstedt.kvitt.fakes.FakeStore
|
||||
import se.hagstedt.kvitt.model.Invite
|
||||
import se.hagstedt.kvitt.model.InviteId
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.InviteRepository
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.ProfileRepository
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import se.hagstedt.kvitt.model.User
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
|
||||
/**
|
||||
* How a scripted online call ends. [Succeed] runs the shared fake (which still answers `Offline` when the scenario is
|
||||
* offline); [Fail] answers `Failed(code, field)` when online; [Hang] never returns, so a screen stays in its progress
|
||||
* state for a screenshot.
|
||||
*/
|
||||
sealed interface CallOutcome {
|
||||
data object Succeed : CallOutcome
|
||||
data class Fail(val code: String, val field: String? = null) : CallOutcome
|
||||
data object Hang : CallOutcome
|
||||
}
|
||||
|
||||
/** The arguments of one [SessionRepository.pair] call, recorded for tests. */
|
||||
data class PairCall(val serverUrl: String, val code: String, val displayName: String?, val deviceName: String)
|
||||
|
||||
private suspend fun <T> FakeStore.scripted(outcome: CallOutcome, succeed: suspend () -> OnlineResult<T>): OnlineResult<T> = when (outcome) {
|
||||
CallOutcome.Succeed -> succeed()
|
||||
is CallOutcome.Fail -> if (current.online) OnlineResult.Failed(outcome.code, outcome.field) else OnlineResult.Offline
|
||||
CallOutcome.Hang -> awaitCancellation()
|
||||
}
|
||||
|
||||
/** [FakeSessionRepository] with a scripted [pair] outcome. */
|
||||
class ScriptedSessionRepository(private val store: FakeStore, var pairOutcome: CallOutcome = CallOutcome.Succeed) : SessionRepository {
|
||||
private val delegate = FakeSessionRepository(store)
|
||||
val pairCalls = mutableListOf<PairCall>()
|
||||
var acknowledged = 0
|
||||
private set
|
||||
|
||||
override val session: Flow<SessionState> = delegate.session
|
||||
|
||||
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> {
|
||||
pairCalls += PairCall(serverUrl, code, displayName, deviceName)
|
||||
return store.scripted(pairOutcome) { delegate.pair(serverUrl, code, displayName, deviceName) }
|
||||
}
|
||||
|
||||
override suspend fun acknowledgeRevoked() {
|
||||
acknowledged += 1
|
||||
delegate.acknowledgeRevoked()
|
||||
}
|
||||
}
|
||||
|
||||
/** [FakeProfileRepository] with scripted picture and name outcomes. */
|
||||
class ScriptedProfileRepository(
|
||||
private val store: FakeStore,
|
||||
var pictureOutcome: CallOutcome = CallOutcome.Succeed,
|
||||
var nameOutcome: CallOutcome = CallOutcome.Succeed,
|
||||
) : ProfileRepository {
|
||||
private val delegate = FakeProfileRepository(store)
|
||||
val namesSet = mutableListOf<String>()
|
||||
|
||||
override fun household(): Flow<List<User>> = delegate.household()
|
||||
|
||||
override suspend fun setDisplayName(name: String): OnlineResult<Unit> {
|
||||
namesSet += name
|
||||
return store.scripted(nameOutcome) { delegate.setDisplayName(name) }
|
||||
}
|
||||
|
||||
override suspend fun setPicture(bytes: ByteArray, mimeType: String): OnlineResult<Unit> =
|
||||
store.scripted(pictureOutcome) { delegate.setPicture(bytes, mimeType) }
|
||||
|
||||
override suspend fun removePicture(): OnlineResult<Unit> = store.scripted(pictureOutcome) { delegate.removePicture() }
|
||||
}
|
||||
|
||||
/** [FakeInviteRepository] with a scripted [list] outcome (S-3's error state). */
|
||||
class ScriptedInviteRepository(private val store: FakeStore, var listOutcome: CallOutcome = CallOutcome.Succeed) : InviteRepository {
|
||||
private val delegate = FakeInviteRepository(store)
|
||||
|
||||
override suspend fun list(): OnlineResult<List<Invite>> = store.scripted(listOutcome) { delegate.list() }
|
||||
|
||||
override suspend fun create(kind: InviteKind, targetUser: UserId?): OnlineResult<Invite> = delegate.create(kind, targetUser)
|
||||
|
||||
override suspend fun cancel(id: InviteId): OnlineResult<Unit> = delegate.cancel(id)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding
|
||||
|
||||
import se.hagstedt.kvitt.design.component.INVITE_LENGTH
|
||||
import se.hagstedt.kvitt.design.component.normalizeInviteCode
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLDecoder
|
||||
|
||||
/** What "Klistra in" found on the clipboard: a normalised 12-character code and, from a link, the server. */
|
||||
data class PastedInvite(val code: String, val serverUrl: String?)
|
||||
|
||||
/**
|
||||
* Reads pasted text on S-1 (design-spec S-1 "Klistra in"): an invite link `kvitt://invite?server=…&code=…`, a landing
|
||||
* link `https://<server>/i/<CODE>` (AC-2.5, AC-2.6), or a bare code in any spelling §8.3 normalises. Anything else is
|
||||
* null. Opening the app from a link is `:app`'s; this only reads the clipboard.
|
||||
*/
|
||||
object InviteLink {
|
||||
fun parse(text: String): PastedInvite? {
|
||||
val trimmed = text.trim()
|
||||
val uri = try {
|
||||
URI(trimmed)
|
||||
} catch (_: URISyntaxException) {
|
||||
null
|
||||
}
|
||||
return when (uri?.scheme?.lowercase()) {
|
||||
"kvitt" -> fromAppLink(uri)
|
||||
"http", "https" -> fromLandingLink(uri)
|
||||
else -> code(trimmed)?.let { PastedInvite(it, null) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun fromAppLink(uri: URI): PastedInvite? {
|
||||
if (uri.schemeSpecificPart?.startsWith("//invite") != true) return null
|
||||
val query = uri.rawQuery ?: return null
|
||||
val params = query.split('&').mapNotNull { pair ->
|
||||
val key = pair.substringBefore('=', "")
|
||||
if (key.isEmpty()) null else key to URLDecoder.decode(pair.substringAfter('='), Charsets.UTF_8)
|
||||
}.toMap()
|
||||
val code = params["code"]?.let(::code) ?: return null
|
||||
return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() })
|
||||
}
|
||||
|
||||
private fun fromLandingLink(uri: URI): PastedInvite? {
|
||||
val path = uri.rawPath ?: return null
|
||||
val segments = path.trim('/').split('/')
|
||||
if (segments.size != 2 || segments[0] != "i") return null
|
||||
val code = code(segments[1]) ?: return null
|
||||
val port = if (uri.port == -1) "" else ":${uri.port}"
|
||||
return PastedInvite(code, "${uri.scheme.lowercase()}://${uri.host}$port")
|
||||
}
|
||||
|
||||
/** The normalised code when [text] normalises to exactly 12 alphabet characters (§8.3), else null. */
|
||||
private fun code(text: String): String? {
|
||||
val stripped = text.uppercase().filter { it != '-' && !it.isWhitespace() }
|
||||
val normalised = normalizeInviteCode(stripped)
|
||||
return normalised.takeIf { it.length == INVITE_LENGTH && stripped.length == INVITE_LENGTH }
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
}
|
||||
-14
@@ -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)
|
||||
}
|
||||
+74
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+284
@@ -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)
|
||||
}
|
||||
}
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.design.component.INVITE_LENGTH
|
||||
import se.hagstedt.kvitt.design.component.normalizeInviteCode
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.Role
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import javax.inject.Inject
|
||||
|
||||
/** S-1's two forms: a first pairing (with "Ditt namn") or "Para ihop telefonen igen" after 401 `unauthorized` (§14.4.8). */
|
||||
enum class PairingMode { First, Again }
|
||||
|
||||
/** A failed pairing, by where it is shown (design-spec S-1 "Interactions"). */
|
||||
enum class PairingError {
|
||||
// Under the code field.
|
||||
InviteInvalid,
|
||||
RateLimited,
|
||||
CodeLength,
|
||||
|
||||
// Under the server row, which expands.
|
||||
Network,
|
||||
HttpsRequired,
|
||||
|
||||
// Snackbar: errors no field is at fault for (design-spec §1 conventions).
|
||||
AppTooOld,
|
||||
Server,
|
||||
Validation,
|
||||
}
|
||||
|
||||
/** Where S-1 goes after a successful pairing. */
|
||||
enum class PairingDone {
|
||||
/** A `member` on a first pairing confirms the profile first (AC-2.10). */
|
||||
ProfileStep,
|
||||
|
||||
/** Everyone else goes to Home (its loading state until the first sync ends). */
|
||||
Home,
|
||||
}
|
||||
|
||||
/** The state of S-1. [code] is normalised (§8.3), at most 12 characters, without hyphens. */
|
||||
data class PairingUiState(
|
||||
val mode: PairingMode = PairingMode.First,
|
||||
val code: String = "",
|
||||
val name: String = "",
|
||||
val serverUrl: String = RELEASE_SERVER_URL,
|
||||
val serverExpanded: Boolean = false,
|
||||
val pairing: Boolean = false,
|
||||
val error: PairingError? = null,
|
||||
val done: PairingDone? = null,
|
||||
) {
|
||||
val nameRequired: Boolean get() = mode == PairingMode.First
|
||||
|
||||
/** The display name is 1 to 40 code points after trimming (§9.6). */
|
||||
val nameTooLong: Boolean get() = nameRequired && name.trim().let { it.codePointCount(0, it.length) } > NAME_MAX
|
||||
|
||||
/** "Para ihop" is enabled when the code has 12 characters, the name is set (first pairing), the address is well formed. */
|
||||
val canPair: Boolean
|
||||
get() = !pairing && code.length == INVITE_LENGTH && ServerAddress.isWellFormed(serverUrl) &&
|
||||
(!nameRequired || (name.isNotBlank() && !nameTooLong))
|
||||
|
||||
companion object {
|
||||
const val NAME_MAX = 40
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* State holder of S-1 Pairing (design-spec S-1; AC-2.4, AC-2.6 to AC-2.8, AC-2.10; §8.3; §14.4.8). Pairing never
|
||||
* queues: offline, the call reports the network error. [allowLanHttp] is true in debug builds (AC-2.8).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PairingViewModel internal constructor(
|
||||
private val session: SessionRepository,
|
||||
private val allowLanHttp: Boolean,
|
||||
) : ViewModel() {
|
||||
@Inject
|
||||
constructor(session: SessionRepository) : this(session, BuildConfig.DEBUG)
|
||||
|
||||
private val mutable = MutableStateFlow(PairingUiState())
|
||||
val state: StateFlow<PairingUiState> = mutable.asStateFlow()
|
||||
private var prefilled = false
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
session.session.collect { s ->
|
||||
mutable.update { it.copy(mode = if (s is SessionState.Unrecognised) PairingMode.Again else PairingMode.First) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the fields from an invite link once (AC-2.6); `:app` parses the intent. The server row opens expanded when
|
||||
* the link names a server other than the release default (design-spec S-1).
|
||||
*/
|
||||
fun prefill(serverUrl: String?, code: String?) {
|
||||
if (prefilled || (serverUrl == null && code == null)) return
|
||||
prefilled = true
|
||||
mutable.update { s ->
|
||||
val server = serverUrl?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() }
|
||||
s.copy(
|
||||
code = code?.let(::normalizeInviteCode) ?: s.code,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onCodeChange(text: String) = mutable.update {
|
||||
it.copy(code = normalizeInviteCode(text), error = it.error.takeUnless { e -> e.isCodeError })
|
||||
}
|
||||
|
||||
fun onNameChange(text: String) = mutable.update { it.copy(name = text) }
|
||||
|
||||
fun onServerChange(text: String) = mutable.update {
|
||||
it.copy(serverUrl = text, error = it.error.takeUnless { e -> e.isServerError })
|
||||
}
|
||||
|
||||
fun onToggleServer() = mutable.update { it.copy(serverExpanded = !it.serverExpanded) }
|
||||
|
||||
/** "Klistra in": a code or an invite link fills code (and server); anything else says the code has 12 characters. */
|
||||
fun onPaste(text: String?) {
|
||||
val invite = text?.let(InviteLink::parse)
|
||||
mutable.update { s ->
|
||||
if (invite == null) {
|
||||
s.copy(error = PairingError.CodeLength)
|
||||
} else {
|
||||
val server = invite.serverUrl
|
||||
s.copy(
|
||||
code = invite.code,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "Para ihop" with the phone's model as the device name (`Build.MODEL`, design-spec S-1). */
|
||||
fun pair(deviceName: String) {
|
||||
val s = mutable.value
|
||||
if (!s.canPair) return
|
||||
val server = ServerAddress.normalise(s.serverUrl)
|
||||
if (ServerAddress.check(server, allowLanHttp) == ServerAddressProblem.HttpsRequired) {
|
||||
mutable.update { it.copy(error = PairingError.HttpsRequired, serverExpanded = true) }
|
||||
return
|
||||
}
|
||||
val name = if (s.nameRequired) s.name.trim() else null
|
||||
mutable.update { it.copy(pairing = true, error = null) }
|
||||
viewModelScope.launch {
|
||||
val result = session.pair(server, s.code, name, deviceName)
|
||||
mutable.update { current ->
|
||||
when (result) {
|
||||
is OnlineResult.Ok -> current.copy(
|
||||
pairing = false,
|
||||
done = if (s.mode == PairingMode.First && result.value.role == Role.MEMBER) PairingDone.ProfileStep else PairingDone.Home,
|
||||
)
|
||||
OnlineResult.Offline -> current.copy(pairing = false, error = PairingError.Network, serverExpanded = true)
|
||||
is OnlineResult.Failed -> {
|
||||
val error = pairingError(result.code)
|
||||
current.copy(pairing = false, error = error, serverExpanded = current.serverExpanded || error.isServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The screen has navigated after [PairingUiState.done]. */
|
||||
fun onDoneHandled() = mutable.update { it.copy(done = null) }
|
||||
|
||||
/** The snackbar error has been shown. */
|
||||
fun onErrorShown() = mutable.update { if (it.error?.isSnackbar == true) it.copy(error = null) else it }
|
||||
}
|
||||
|
||||
/** §14.3.2 error code of E-3 (or `network`, D-029) to S-1's error. */
|
||||
internal fun pairingError(code: String): PairingError = when (code) {
|
||||
"invite_invalid" -> PairingError.InviteInvalid
|
||||
"rate_limited" -> PairingError.RateLimited
|
||||
"network" -> PairingError.Network
|
||||
"protocol_unsupported" -> PairingError.AppTooOld
|
||||
"internal", "unavailable", "not_implemented" -> PairingError.Server
|
||||
else -> PairingError.Validation
|
||||
}
|
||||
|
||||
internal val PairingError?.isCodeError: Boolean
|
||||
get() = this == PairingError.InviteInvalid || this == PairingError.RateLimited || this == PairingError.CodeLength
|
||||
|
||||
internal val PairingError?.isServerError: Boolean
|
||||
get() = this == PairingError.Network || this == PairingError.HttpsRequired
|
||||
|
||||
internal val PairingError?.isSnackbar: Boolean
|
||||
get() = this == PairingError.AppTooOld || this == PairingError.Server || this == PairingError.Validation
|
||||
+285
@@ -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
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.ProfileRepository
|
||||
import se.hagstedt.kvitt.model.Role
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import se.hagstedt.kvitt.model.User
|
||||
import javax.inject.Inject
|
||||
|
||||
/** A snackbar of S-2 (design-spec S-2 "error"; texts from `:core:design`). */
|
||||
enum class ProfileStepMessage {
|
||||
PictureTooLarge,
|
||||
PictureUnsupported,
|
||||
PictureInvalid,
|
||||
PictureFailed,
|
||||
CouldNotReachServer,
|
||||
NameRefused,
|
||||
}
|
||||
|
||||
/** The state of S-2. [me] and [owner] are null until the household has loaded. */
|
||||
data class ProfileStepUiState(
|
||||
val me: User? = null,
|
||||
val owner: User? = null,
|
||||
val name: String = "",
|
||||
val online: Boolean = true,
|
||||
val uploading: Boolean = false,
|
||||
val saving: Boolean = false,
|
||||
val message: ProfileStepMessage? = null,
|
||||
val done: Boolean = false,
|
||||
) {
|
||||
val nameBlank: Boolean get() = name.isBlank()
|
||||
val nameTooLong: Boolean get() = name.trim().let { it.codePointCount(0, it.length) } > PairingUiState.NAME_MAX
|
||||
val hasPicture: Boolean get() = me?.pictureVersion != null
|
||||
|
||||
/** "Fortsätt": disabled while the picture uploads and while the name is blank or too long. */
|
||||
val canContinue: Boolean get() = me != null && !uploading && !saving && !nameBlank && !nameTooLong
|
||||
|
||||
/** Picture actions are online only (§9.9, AC-4.3). */
|
||||
val canChangePicture: Boolean get() = me != null && online && !uploading && !saving
|
||||
}
|
||||
|
||||
/**
|
||||
* State holder of S-2 Partner profile step (design-spec S-2; AC-2.10, AC-4.3, §9.9): the name prefilled from the
|
||||
* pairing call, an optional picture uploaded right after it is picked, then Home.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ProfileStepViewModel @Inject constructor(
|
||||
session: SessionRepository,
|
||||
private val profile: ProfileRepository,
|
||||
connectivity: ConnectivityRepository,
|
||||
) : ViewModel() {
|
||||
private val mutable = MutableStateFlow(ProfileStepUiState())
|
||||
val state: StateFlow<ProfileStepUiState> = mutable.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
combine(session.session, profile.household(), connectivity.online) { s, users, online -> Triple(s, users, online) }
|
||||
.collect { (s, users, online) ->
|
||||
val meId = (s as? SessionState.Paired)?.me?.id
|
||||
val me = users.firstOrNull { it.id == meId } ?: (s as? SessionState.Paired)?.me
|
||||
val owner = users.firstOrNull { it.role == Role.ADMIN && it.id != meId }
|
||||
?: (s as? SessionState.Paired)?.partner
|
||||
mutable.update { current ->
|
||||
current.copy(
|
||||
me = me,
|
||||
owner = owner,
|
||||
// Prefilled once from the name the pairing call stored (AC-2.10).
|
||||
name = if (current.me == null && me != null) me.displayName else current.name,
|
||||
online = online,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onNameChange(text: String) = mutable.update { it.copy(name = text) }
|
||||
|
||||
/** A picture from the photo picker: upload starts at once (design-spec S-2). */
|
||||
fun onPicturePicked(bytes: ByteArray, mimeType: String) {
|
||||
if (!mutable.value.canChangePicture) return
|
||||
mutable.update { it.copy(uploading = true, message = null) }
|
||||
viewModelScope.launch {
|
||||
val result = profile.setPicture(bytes, mimeType)
|
||||
mutable.update { it.copy(uploading = false, message = pictureMessage(result)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** The picker returned a file that could not be read. */
|
||||
fun onPictureUnreadable() = mutable.update { it.copy(message = ProfileStepMessage.PictureInvalid) }
|
||||
|
||||
fun onRemovePicture() {
|
||||
if (!mutable.value.canChangePicture) return
|
||||
mutable.update { it.copy(uploading = true, message = null) }
|
||||
viewModelScope.launch {
|
||||
val result = profile.removePicture()
|
||||
mutable.update { it.copy(uploading = false, message = pictureMessage(result)) }
|
||||
}
|
||||
}
|
||||
|
||||
/** "Fortsätt": saves the name when it changed (failure: snackbar, stay), then Home. */
|
||||
fun onContinue() {
|
||||
val s = mutable.value
|
||||
if (!s.canContinue) return
|
||||
val name = s.name.trim()
|
||||
if (name == s.me?.displayName) {
|
||||
mutable.update { it.copy(done = true) }
|
||||
return
|
||||
}
|
||||
mutable.update { it.copy(saving = true, message = null) }
|
||||
viewModelScope.launch {
|
||||
val result = profile.setDisplayName(name)
|
||||
mutable.update {
|
||||
when (result) {
|
||||
is OnlineResult.Ok -> it.copy(saving = false, done = true)
|
||||
OnlineResult.Offline -> it.copy(saving = false, message = ProfileStepMessage.CouldNotReachServer)
|
||||
is OnlineResult.Failed -> it.copy(
|
||||
saving = false,
|
||||
message = if (result.code == "network") ProfileStepMessage.CouldNotReachServer else ProfileStepMessage.NameRefused,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onMessageShown() = mutable.update { it.copy(message = null) }
|
||||
|
||||
fun onDoneHandled() = mutable.update { it.copy(done = false) }
|
||||
}
|
||||
|
||||
/** E-10 / E-12 result to S-2's snackbar (design-spec S-2 "error"); null on success. */
|
||||
internal fun pictureMessage(result: OnlineResult<Unit>): ProfileStepMessage? = when (result) {
|
||||
is OnlineResult.Ok -> null
|
||||
OnlineResult.Offline -> ProfileStepMessage.CouldNotReachServer
|
||||
is OnlineResult.Failed -> when (result.code) {
|
||||
"payload_too_large" -> ProfileStepMessage.PictureTooLarge
|
||||
"unsupported_media_type" -> ProfileStepMessage.PictureUnsupported
|
||||
"image_invalid" -> ProfileStepMessage.PictureInvalid
|
||||
"network" -> ProfileStepMessage.CouldNotReachServer
|
||||
else -> ProfileStepMessage.PictureFailed
|
||||
}
|
||||
}
|
||||
+90
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
/** State holder of S-18 Device removed (AC-3.3): "Para ihop igen" acknowledges the revocation, then S-1. */
|
||||
@HiltViewModel
|
||||
class RemovedViewModel @Inject constructor(private val session: SessionRepository) : ViewModel() {
|
||||
private val mutable = MutableStateFlow(false)
|
||||
|
||||
/** True once the revocation is acknowledged (local data wiped, session `Unpaired`): go to S-1. */
|
||||
val acknowledged: StateFlow<Boolean> = mutable.asStateFlow()
|
||||
|
||||
fun onPairAgain() {
|
||||
viewModelScope.launch {
|
||||
session.acknowledgeRevoked()
|
||||
mutable.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun onNavigated() {
|
||||
mutable.value = false
|
||||
}
|
||||
}
|
||||
+55
@@ -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)
|
||||
}
|
||||
}
|
||||
+175
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.InviteRepository
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import se.hagstedt.kvitt.model.User
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The state of S-3. [inviteExpiresAt] is the RFC 3339 expiry of the pending partner invite, null when there is none
|
||||
* or it is not known (offline: invites are online only). [loadError]: the §14.3.2 code (or `network`) when the invite
|
||||
* list could not be fetched.
|
||||
*/
|
||||
data class WaitingUiState(
|
||||
val me: User? = null,
|
||||
val online: Boolean = true,
|
||||
val inviteExpiresAt: String? = null,
|
||||
val loadError: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* State holder of S-3 Waiting for partner (design-spec S-3; AC-5.1, AC-2.11): the admin alone in the household. The
|
||||
* pending invite is fetched (E-8) whenever the phone comes online.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class WaitingViewModel @Inject constructor(
|
||||
private val session: SessionRepository,
|
||||
private val invites: InviteRepository,
|
||||
private val connectivity: ConnectivityRepository,
|
||||
) : ViewModel() {
|
||||
private val mutable = MutableStateFlow(WaitingUiState())
|
||||
val state: StateFlow<WaitingUiState> = mutable.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
session.session.collect { s -> mutable.update { it.copy(me = (s as? SessionState.Paired)?.me) } }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
connectivity.online.distinctUntilChanged().collect { online ->
|
||||
mutable.update { it.copy(online = online, inviteExpiresAt = if (online) it.inviteExpiresAt else null, loadError = null) }
|
||||
if (online) load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the pending partner invite; "Försök igen" calls it again. */
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
val result = invites.list()
|
||||
mutable.update { s ->
|
||||
when (result) {
|
||||
is OnlineResult.Ok -> s.copy(
|
||||
inviteExpiresAt = result.value.filter { it.kind == InviteKind.PARTNER }.maxByOrNull { it.expiresAt }?.expiresAt,
|
||||
loadError = null,
|
||||
)
|
||||
OnlineResult.Offline -> s.copy(inviteExpiresAt = null, loadError = null)
|
||||
is OnlineResult.Failed -> s.copy(inviteExpiresAt = null, loadError = result.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onErrorShown() = mutable.update { it.copy(loadError = null) }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Strings of :feature:onboarding (S-1, S-2, S-3, S-18), from docs/ux/strings.csv (keys onboarding_*, D-017).
|
||||
OnboardingStringsTest checks every key and text against the CSV; change the CSV first. -->
|
||||
<resources>
|
||||
<string name="onboarding_code_paste">Klistra in</string>
|
||||
<string name="onboarding_name_label">Ditt namn</string>
|
||||
<string name="onboarding_code_help">Koden får du av %1$s: från en länk eller från Inställningar › Bjud in partner.</string>
|
||||
<string name="onboarding_code_help_generic">Koden får du från en länk, eller så skrivs den ut när servern sätts upp.</string>
|
||||
<string name="onboarding_server_label">Serveradress</string>
|
||||
<string name="onboarding_pair">Para ihop</string>
|
||||
<string name="onboarding_pairing">Parar ihop…</string>
|
||||
<string name="onboarding_again_title">Para ihop telefonen igen</string>
|
||||
<string name="onboarding_again_body">Dina utgifter finns kvar på telefonen.</string>
|
||||
<string name="onboarding_error_invite_invalid">Koden fungerar inte. Den kan ha gått ut eller redan använts. Be om en ny.</string>
|
||||
<string name="onboarding_error_network">Kunde inte nå servern. Kontrollera adressen och anslutningen.</string>
|
||||
<string name="onboarding_error_rate_limited">För många försök. Vänta en stund och försök igen.</string>
|
||||
<string name="onboarding_error_https">Adressen måste börja med https://</string>
|
||||
<string name="onboarding_error_code_length">Koden har 12 tecken</string>
|
||||
<string name="onboarding_error_name_required">Skriv ditt namn</string>
|
||||
<string name="onboarding_profile_title">Din profil</string>
|
||||
<string name="onboarding_profile_joining">Du går med i %1$ss hushåll</string>
|
||||
<string name="onboarding_profile_hint">Så här ser %1$s dig i Kvitt. Du kan ändra det senare under Inställningar › Profil.</string>
|
||||
<string name="onboarding_waiting_title">Väntar på din partner</string>
|
||||
<string name="onboarding_waiting_body">Kvitt behöver er båda innan ni kan börja. Bjud in din partner med en länk.</string>
|
||||
<string name="onboarding_waiting_invite_sent">Inbjudan skickad</string>
|
||||
<string name="onboarding_waiting_invite_expires">Inbjudan gäller till %1$s</string>
|
||||
<string name="onboarding_waiting_footer">Konton, utgifter och betalningar blir tillgängliga när din partner har gått med.</string>
|
||||
<string name="onboarding_waiting_partner_missing">Partner saknas</string>
|
||||
<string name="onboarding_removed_title">Den här telefonen har tagits bort</string>
|
||||
<string name="onboarding_removed_body">Åtkomsten togs bort och allt Kvitt-data på telefonen har raderats.</string>
|
||||
<string name="onboarding_removed_new_code">Vill du använda Kvitt igen behöver du en ny inbjudningskod.</string>
|
||||
<string name="onboarding_removed_pair_again">Para ihop igen</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Strings of :feature:onboarding (S-1, S-2, S-3, S-18), from docs/ux/strings.csv (keys onboarding_*, D-017).
|
||||
OnboardingStringsTest checks every key and text against the CSV; change the CSV first. -->
|
||||
<resources>
|
||||
<string name="onboarding_code_paste">Paste</string>
|
||||
<!-- placeholder of the empty code field -->
|
||||
<string name="onboarding_code_placeholder" translatable="false">XXXX-XXXX-XXXX</string>
|
||||
<string name="onboarding_name_label">Your name</string>
|
||||
<!-- %1$s admin name when known from the link; else use onboarding_code_help_generic -->
|
||||
<string name="onboarding_code_help">You get the code from %1$s: from a link or from Settings › Invite partner.</string>
|
||||
<string name="onboarding_code_help_generic">You get the code from a link, or it is printed when the server is set up.</string>
|
||||
<string name="onboarding_server_label">Server address</string>
|
||||
<string name="onboarding_pair">Pair</string>
|
||||
<string name="onboarding_pairing">Pairing…</string>
|
||||
<!-- §14.4.8 -->
|
||||
<string name="onboarding_again_title">Pair this phone again</string>
|
||||
<string name="onboarding_again_body">Your expenses are still on this phone.</string>
|
||||
<string name="onboarding_error_invite_invalid">This code does not work. It may have expired or already been used. Ask for a new one.</string>
|
||||
<string name="onboarding_error_network">Could not reach the server. Check the address and your connection.</string>
|
||||
<string name="onboarding_error_rate_limited">Too many attempts. Wait a while and try again.</string>
|
||||
<!-- AC-2.8 -->
|
||||
<string name="onboarding_error_https">The address must start with https://</string>
|
||||
<string name="onboarding_error_code_length">The code has 12 characters</string>
|
||||
<string name="onboarding_error_name_required">Enter your name</string>
|
||||
<string name="onboarding_profile_title">Your profile</string>
|
||||
<!-- sv genitive: name + s (no apostrophe); names ending in s get no extra s (handled in code: Jonas hushåll) -->
|
||||
<string name="onboarding_profile_joining">You are joining %1$s\'s household</string>
|
||||
<!-- %1$s the admin's name -->
|
||||
<string name="onboarding_profile_hint">This is how %1$s sees you in Kvitt. You can change it later under Settings › Profile.</string>
|
||||
<string name="onboarding_waiting_title">Waiting for your partner</string>
|
||||
<string name="onboarding_waiting_body">Kvitt needs both of you before you can start. Invite your partner with a link.</string>
|
||||
<!-- pending invite row title -->
|
||||
<string name="onboarding_waiting_invite_sent">Invite sent</string>
|
||||
<!-- %1$s weekday + date + time, e.g. mån 19 okt. 21:40 -->
|
||||
<string name="onboarding_waiting_invite_expires">Invite valid until %1$s</string>
|
||||
<string name="onboarding_waiting_footer">Accounts, expenses and payments become available when your partner has joined.</string>
|
||||
<!-- TalkBack of the empty seat -->
|
||||
<string name="onboarding_waiting_partner_missing">Partner has not joined</string>
|
||||
<string name="onboarding_removed_title">This phone has been removed</string>
|
||||
<!-- AC-3.3 -->
|
||||
<string name="onboarding_removed_body">Access was removed and all Kvitt data on the phone has been deleted.</string>
|
||||
<string name="onboarding_removed_new_code">To use Kvitt again you need a new invite code.</string>
|
||||
<string name="onboarding_removed_pair_again">Pair again</string>
|
||||
</resources>
|
||||
+38
@@ -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"))
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.w3c.dom.Element
|
||||
import java.io.File
|
||||
import javax.xml.parsers.DocumentBuilderFactory
|
||||
|
||||
/** The module's strings are exactly the `onboarding_*` rows of docs/ux/strings.csv, in English and Swedish (D-017). */
|
||||
class OnboardingStringsTest {
|
||||
private data class Row(val key: String, val en: String, val sv: String, val notes: String)
|
||||
|
||||
private fun csv(): List<Row> {
|
||||
val text = File("../../../docs/ux/strings.csv").readText()
|
||||
val rows = mutableListOf<List<String>>()
|
||||
var field = StringBuilder()
|
||||
var record = mutableListOf<String>()
|
||||
var quoted = false
|
||||
var i = 0
|
||||
while (i < text.length) {
|
||||
val c = text[i]
|
||||
when {
|
||||
quoted && c == '"' && i + 1 < text.length && text[i + 1] == '"' -> { field.append('"'); i++ }
|
||||
c == '"' -> quoted = !quoted
|
||||
!quoted && c == ',' -> { record.add(field.toString()); field = StringBuilder() }
|
||||
!quoted && c == '\n' -> { record.add(field.toString()); rows.add(record); record = mutableListOf(); field = StringBuilder() }
|
||||
c == '\r' -> Unit
|
||||
else -> field.append(c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
if (field.isNotEmpty() || record.isNotEmpty()) { record.add(field.toString()); rows.add(record) }
|
||||
return rows.drop(1).filter { it.size >= 4 && it[0].startsWith("onboarding_") }.map { Row(it[0], it[1], it[2], it[3]) }
|
||||
}
|
||||
|
||||
private fun resources(folder: String): Map<String, String> {
|
||||
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(File("src/main/res/$folder/strings.xml"))
|
||||
val strings = doc.getElementsByTagName("string")
|
||||
return (0 until strings.length).associate { i ->
|
||||
val e = strings.item(i) as Element
|
||||
e.getAttribute("name") to e.textContent.replace("\\'", "'").replace("\\\"", "\"").replace("\\\\", "\\")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun englishMatchesTheCsv() {
|
||||
val rows = csv()
|
||||
val en = resources("values")
|
||||
assertEquals(rows.map { it.key }.toSet(), en.keys)
|
||||
rows.forEach { assertEquals(it.key, it.en, en[it.key]) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun swedishMatchesTheCsv() {
|
||||
val rows = csv().filter { "translatable=false" !in it.notes }
|
||||
val sv = resources("values-sv")
|
||||
assertEquals(rows.map { it.key }.toSet(), sv.keys)
|
||||
rows.forEach { assertEquals(it.key, it.sv, sv[it.key]) }
|
||||
}
|
||||
}
|
||||
+161
@@ -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)
|
||||
}
|
||||
}
|
||||
+176
@@ -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)
|
||||
}
|
||||
}
|
||||
+48
@@ -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/ "))
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding.screenshot
|
||||
|
||||
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.ParameterizedRobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* One golden per screen state × light/dark × Swedish/English (PLAN-M7a §5, §7), plus 200 % for the states marked so.
|
||||
* `check` verifies them; re-record an intended change with
|
||||
* `tools/gradle-serial.sh :feature:onboarding:recordRoborazziDebug` and open every changed PNG.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(ParameterizedRobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [36], qualifiers = "w411dp-h891dp-xxhdpi")
|
||||
class OnboardingScreenshotTest(private val name: String) {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun mainDispatcher() = Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
|
||||
@After
|
||||
fun resetMainDispatcher() = Dispatchers.resetMain()
|
||||
|
||||
@Test
|
||||
fun screen() {
|
||||
val state = OnboardingStates.all.first { it.name == name }
|
||||
val content = state.render()
|
||||
ScreenSnapshots.snap(compose, state.name, state.largeFont, content)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
|
||||
fun states(): List<Array<Any>> = OnboardingStates.all.map { arrayOf<Any>(it.name) }
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding.screenshot
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import se.hagstedt.kvitt.fakes.FakeRepositories
|
||||
import se.hagstedt.kvitt.fakes.FakeScenario
|
||||
import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
|
||||
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
|
||||
import se.hagstedt.kvitt.fakes.onboarding.ScriptedInviteRepository
|
||||
import se.hagstedt.kvitt.fakes.onboarding.ScriptedProfileRepository
|
||||
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
|
||||
import se.hagstedt.kvitt.feature.onboarding.PairingActions
|
||||
import se.hagstedt.kvitt.feature.onboarding.PairingContent
|
||||
import se.hagstedt.kvitt.feature.onboarding.PairingViewModel
|
||||
import se.hagstedt.kvitt.feature.onboarding.ProfileStepActions
|
||||
import se.hagstedt.kvitt.feature.onboarding.ProfileStepContent
|
||||
import se.hagstedt.kvitt.feature.onboarding.ProfileStepViewModel
|
||||
import se.hagstedt.kvitt.feature.onboarding.RELEASE_SERVER_URL
|
||||
import se.hagstedt.kvitt.feature.onboarding.RemovedContent
|
||||
import se.hagstedt.kvitt.feature.onboarding.WaitingActions
|
||||
import se.hagstedt.kvitt.feature.onboarding.WaitingContent
|
||||
import se.hagstedt.kvitt.feature.onboarding.WaitingViewModel
|
||||
|
||||
/** One screen state: the golden's base name, whether a 200 % capture is added, and how to render it. */
|
||||
class ScreenState(val name: String, val largeFont: Boolean = false, val render: () -> @Composable () -> Unit)
|
||||
|
||||
private const val CODE = "7K3MQX9D2HPA"
|
||||
private const val LAN_SERVER = "http://192.168.1.210:8080"
|
||||
|
||||
/**
|
||||
* Every state of S-1, S-2, S-3 and S-18 (design-spec §4 and PLAN-M7a §5). Each is produced by the screen's real
|
||||
* ViewModel over a `:core:fakes` scenario (with a scripted outcome where a scenario cannot express it); the captured
|
||||
* UI state is then rendered by the stateless screen. `Dispatchers.Main` is a test dispatcher (see the test class).
|
||||
*/
|
||||
object OnboardingStates {
|
||||
private fun pairing(
|
||||
scenario: FakeScenario,
|
||||
outcome: CallOutcome = CallOutcome.Succeed,
|
||||
allowLanHttp: Boolean = false,
|
||||
act: PairingViewModel.() -> Unit,
|
||||
): () -> @Composable () -> Unit = {
|
||||
val store = FakeRepositories(scenario).store
|
||||
val vm = PairingViewModel(ScriptedSessionRepository(store, outcome), allowLanHttp)
|
||||
vm.act()
|
||||
val state = vm.state.value
|
||||
{ PairingContent(state, PairingActions()) }
|
||||
}
|
||||
|
||||
private fun profile(
|
||||
scenario: FakeScenario,
|
||||
pictureOutcome: CallOutcome = CallOutcome.Succeed,
|
||||
act: ProfileStepViewModel.() -> Unit = {},
|
||||
): () -> @Composable () -> Unit = {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val vm = ProfileStepViewModel(fakes.session, ScriptedProfileRepository(fakes.store, pictureOutcome), fakes.connectivity)
|
||||
vm.act()
|
||||
val state = vm.state.value
|
||||
{ ProfileStepContent(state, ProfileStepActions()) }
|
||||
}
|
||||
|
||||
private fun waiting(scenario: FakeScenario, listOutcome: CallOutcome = CallOutcome.Succeed): () -> @Composable () -> Unit = {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val vm = WaitingViewModel(fakes.session, ScriptedInviteRepository(fakes.store, listOutcome), fakes.connectivity)
|
||||
val state = vm.state.value
|
||||
{ WaitingContent(state, WaitingActions()) }
|
||||
}
|
||||
|
||||
private fun PairingViewModel.fillFromLink(server: String = RELEASE_SERVER_URL) {
|
||||
prefill(server, CODE)
|
||||
onNameChange("Anna")
|
||||
}
|
||||
|
||||
private val picture = ByteArray(16)
|
||||
|
||||
val all: List<ScreenState> = listOf(
|
||||
// S-1 Pairing.
|
||||
ScreenState("s1_pairing_empty", render = pairing(OnboardingScenarios.unpaired) {}),
|
||||
ScreenState("s1_pairing", largeFont = true, render = pairing(OnboardingScenarios.unpaired) { fillFromLink() }),
|
||||
ScreenState("s1_pairing_link_lan", render = pairing(OnboardingScenarios.unpaired, allowLanHttp = true) { fillFromLink(LAN_SERVER) }),
|
||||
ScreenState("s1_pairing_loading", render = pairing(OnboardingScenarios.unpaired, CallOutcome.Hang) { fillFromLink(); pair("Galaxy S26") }),
|
||||
ScreenState(
|
||||
"s1_pairing_error",
|
||||
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("invite_invalid")) { fillFromLink(); pair("Galaxy S26") },
|
||||
),
|
||||
ScreenState(
|
||||
"s1_pairing_error_network",
|
||||
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("network")) { fillFromLink(); pair("Galaxy S26") },
|
||||
),
|
||||
ScreenState(
|
||||
"s1_pairing_error_rate_limited",
|
||||
render = pairing(OnboardingScenarios.unpaired, CallOutcome.Fail("rate_limited")) { fillFromLink(); pair("Galaxy S26") },
|
||||
),
|
||||
ScreenState("s1_pairing_error_https", render = pairing(OnboardingScenarios.unpaired) { fillFromLink(LAN_SERVER); pair("Galaxy S26") }),
|
||||
ScreenState("s1_pairing_offline", render = pairing(OnboardingScenarios.unpairedOffline) { fillFromLink(); pair("Galaxy S26") }),
|
||||
ScreenState("s1_pairing_again", render = pairing(OnboardingScenarios.unrecognised) {}),
|
||||
// S-2 Partner profile step.
|
||||
ScreenState("s2_profile_step", render = profile(OnboardingScenarios.partnerJustPaired)),
|
||||
ScreenState("s2_profile_step_picture", render = profile(OnboardingScenarios.partnerWithPicture)),
|
||||
ScreenState(
|
||||
"s2_profile_step_loading",
|
||||
render = profile(OnboardingScenarios.partnerJustPaired, CallOutcome.Hang) { onPicturePicked(picture, "image/jpeg") },
|
||||
),
|
||||
ScreenState(
|
||||
"s2_profile_step_error",
|
||||
render = profile(OnboardingScenarios.partnerJustPaired, CallOutcome.Fail("payload_too_large")) { onPicturePicked(picture, "image/jpeg") },
|
||||
),
|
||||
ScreenState("s2_profile_step_offline", render = profile(OnboardingScenarios.partnerOffline)),
|
||||
ScreenState("s2_profile_step_long", largeFont = true, render = profile(OnboardingScenarios.partnerLongName)),
|
||||
// S-3 Waiting for partner.
|
||||
ScreenState("s3_waiting", render = waiting(OnboardingScenarios.waitingWithInvite)),
|
||||
ScreenState("s3_waiting_no_invite", render = waiting(OnboardingScenarios.waitingWithoutInvite)),
|
||||
ScreenState("s3_waiting_offline", render = waiting(OnboardingScenarios.waitingOffline)),
|
||||
ScreenState("s3_waiting_error", render = waiting(OnboardingScenarios.waitingWithInvite, CallOutcome.Fail("network"))),
|
||||
// S-18 Device removed.
|
||||
ScreenState("s18_removed", render = { { RemovedContent(onPairAgain = {}) } }),
|
||||
)
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package se.hagstedt.kvitt.feature.onboarding.screenshot
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.test.junit4.ComposeContentTestRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import kotlinx.datetime.TimeZone
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.component.KvittClock
|
||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
import java.util.Locale
|
||||
import kotlin.time.Instant
|
||||
|
||||
/**
|
||||
* Captures a whole screen once per theme and language: `src/test/screenshots/<name>_<sv|en>_<light|dark>.png`, plus
|
||||
* `<name>_sv_light_200.png` when `largeFont` is set (200 % font scale, Swedish, light), as `:core:design`'s Snapshots
|
||||
* do for components. The phone is the Galaxy S26 (411 dp wide, SPEC §13.1); the clock is fixed at 17 October 2026
|
||||
* 14:02 in Stockholm so every render is reproducible.
|
||||
*/
|
||||
object ScreenSnapshots {
|
||||
val clock = KvittClock.fixed(Instant.parse("2026-10-17T12:02:00Z"), TimeZone.of("Europe/Stockholm"))
|
||||
|
||||
private data class Variant(val language: String, val dark: Boolean, val fontScale: Float) {
|
||||
val suffix: String get() = "${language}_${if (dark) "dark" else "light"}" + if (fontScale > 1f) "_200" else ""
|
||||
}
|
||||
|
||||
fun snap(compose: ComposeContentTestRule, name: String, largeFont: Boolean, content: @Composable () -> Unit) {
|
||||
val variants = listOf("sv", "en").flatMap { l -> listOf(false, true).map { Variant(l, it, 1f) } } +
|
||||
if (largeFont) listOf(Variant("sv", dark = false, fontScale = 2f)) else emptyList()
|
||||
var current by mutableStateOf(variants.first())
|
||||
compose.setContent {
|
||||
val variant = current
|
||||
val base = LocalContext.current
|
||||
val baseConfiguration = LocalConfiguration.current
|
||||
val context = remember(variant) {
|
||||
val config = Configuration(baseConfiguration).apply {
|
||||
setLocales(LocaleList(Locale.forLanguageTag(if (variant.language == "sv") "sv-SE" else "en-GB")))
|
||||
fontScale = variant.fontScale
|
||||
uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (variant.dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO
|
||||
}
|
||||
base.createConfigurationContext(config)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
LocalContext provides context,
|
||||
LocalConfiguration provides context.resources.configuration,
|
||||
LocalResources provides context.resources,
|
||||
LocalDensity provides Density(context),
|
||||
LocalKvittClock provides clock,
|
||||
) {
|
||||
KvittTheme(darkTheme = variant.dark) {
|
||||
Box(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
}
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
compose.waitForIdle()
|
||||
compose.onRoot().captureRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user