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