M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)
Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite; X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3); OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+5
-2
@@ -74,6 +74,7 @@ data class AccountDetailActions(
|
||||
* Account detail (S-5). The account id is the navigation argument [AccountDetailViewModel.ARG_ACCOUNT_ID].
|
||||
* [onArchived] is called after archiving with the account's name: `:app` returns to Home and shows
|
||||
* "Vardag arkiverades" with "Ångra" there (design-spec S-5). [onDeleted] after deleting, or when the account is gone.
|
||||
* [snackbarHostState] is hosted in this screen's scaffold, so `:app` can show a result snackbar here (D-071).
|
||||
*/
|
||||
@Composable
|
||||
fun AccountDetailScreen(
|
||||
@@ -86,6 +87,7 @@ fun AccountDetailScreen(
|
||||
onArchived: (AccountId, String) -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
viewModel: AccountDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.ui.collectAsStateWithLifecycle()
|
||||
@@ -118,7 +120,7 @@ fun AccountDetailScreen(
|
||||
onRetry = viewModel::retry,
|
||||
onMessageShown = viewModel::messageShown,
|
||||
)
|
||||
AccountDetailContent(ui, actions, modifier)
|
||||
AccountDetailContent(ui, actions, modifier, snackbarHostState = snackbarHostState)
|
||||
}
|
||||
|
||||
/** The stateless account screen, rendered by [AccountDetailScreen], previews and screenshot tests. */
|
||||
@@ -128,9 +130,10 @@ fun AccountDetailContent(
|
||||
actions: AccountDetailActions,
|
||||
modifier: Modifier = Modifier,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
) {
|
||||
val ready = ui as? AccountDetailUi.Ready
|
||||
val snackbar = remember { SnackbarHostState() }
|
||||
val snackbar = snackbarHostState
|
||||
val scrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 0 } }
|
||||
val fabExpanded by remember { derivedStateOf { !listState.lastScrolledForward } }
|
||||
ready?.message?.let { message -> MessageEffect(message, snackbar, actions) }
|
||||
|
||||
+9
-2
@@ -14,6 +14,7 @@ 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.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@@ -51,16 +52,20 @@ import se.hagstedt.kvitt.design.theme.KvittDimens
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
|
||||
/** S-12 Analytics drill-down (design-spec S-12, AC-14.8). [onOpenExpense] opens S-8. */
|
||||
/**
|
||||
* S-12 Analytics drill-down (design-spec S-12, AC-14.8). [onOpenExpense] opens S-8. [snackbarHostState] is hosted in
|
||||
* this screen's scaffold ("Utgiften togs bort" after deleting an expense opened from here, D-071).
|
||||
*/
|
||||
@Composable
|
||||
fun DrillDownScreen(
|
||||
onOpenExpense: (ExpenseId) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
viewModel: DrillDownViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
DrillDownScreen(state, onOpenExpense, onBack, modifier)
|
||||
DrillDownScreen(state, onOpenExpense, onBack, modifier, snackbarHostState = snackbarHostState)
|
||||
}
|
||||
|
||||
/** The stateless S-12 that previews and screenshot tests render. */
|
||||
@@ -71,11 +76,13 @@ fun DrillDownScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: ScrollState = rememberScrollState(),
|
||||
snackbarHostState: SnackbarHostState? = null,
|
||||
) {
|
||||
val title = drillDownTitle(state)
|
||||
val scrolled by remember(scrollState) { derivedStateOf { scrollState.value > 0 } }
|
||||
KvittScaffold(
|
||||
modifier.fillMaxSize(),
|
||||
snackbarHostState = snackbarHostState,
|
||||
topBar = { KvittTopAppBar(title, navigation = TopBarNavigation.Back, onNavigation = onBack, scrolled = scrolled) },
|
||||
) { padding ->
|
||||
Column(
|
||||
|
||||
+2
-2
@@ -70,8 +70,8 @@ class AnalyticsRulesTest {
|
||||
|
||||
@Test
|
||||
fun loadingOnlyBeforeTheFirstSync() {
|
||||
assertTrue(AnalyticsRules.isFirstSync(SyncSummary(null, 0, 0, clockSkew = false, running = true)))
|
||||
assertFalse(AnalyticsRules.isFirstSync(SyncSummary("2026-10-17T06:00:00.000Z", 3, 0, clockSkew = false, running = false)))
|
||||
assertTrue(AnalyticsRules.isFirstSync(SyncSummary(null, 0, 0, clockSkew = false, running = true, lastAttemptFailed = false)))
|
||||
assertFalse(AnalyticsRules.isFirstSync(SyncSummary("2026-10-17T06:00:00.000Z", 3, 0, clockSkew = false, running = false, lastAttemptFailed = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
-6
@@ -14,11 +14,13 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
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.draw.alpha
|
||||
@@ -29,6 +31,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
import se.hagstedt.kvitt.design.component.KvittScaffold
|
||||
import se.hagstedt.kvitt.design.component.AmountStyle
|
||||
import se.hagstedt.kvitt.design.component.AmountTone
|
||||
import se.hagstedt.kvitt.design.component.BadgeKind
|
||||
@@ -80,22 +83,23 @@ interface ExpenseDetailActions {
|
||||
|
||||
/**
|
||||
* S-8, the expense detail with its timeline (design-spec §4 S-8; SPEC AC-8.11, AC-12.4, AC-12.5). [onEdit] opens S-7
|
||||
* in edit mode; [onDeleted] goes back with the snackbar text "Utgiften togs bort".
|
||||
* in edit mode; [onDeleted] goes back, where "Utgiften togs bort" (`snackbar_expense_deleted`) is shown, as S-10 does.
|
||||
* [snackbarHostState] is hosted in this screen, so `:app` can show a result snackbar here (D-071).
|
||||
*/
|
||||
@Composable
|
||||
fun ExpenseDetailScreen(
|
||||
onBack: () -> Unit,
|
||||
onEdit: (ExpenseId) -> Unit,
|
||||
onDeleted: (String) -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
viewModel: ExpenseDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val deleted = stringResource(DesignR.string.snackbar_expense_deleted)
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted(deleted) }
|
||||
viewModel.eventFlow.collect { event -> if (event == DetailEvent.Deleted) onDeleted() }
|
||||
}
|
||||
ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier)
|
||||
ExpenseDetailContent(state, viewModel, onBack, onEdit, modifier, snackbarHostState)
|
||||
}
|
||||
|
||||
/** The stateless S-8 layout. */
|
||||
@@ -106,10 +110,19 @@ fun ExpenseDetailContent(
|
||||
onBack: () -> Unit,
|
||||
onEdit: (ExpenseId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState? = null,
|
||||
) {
|
||||
// The scaffold only hosts the snackbar; the body keeps its own system-bar padding.
|
||||
KvittScaffold(modifier.fillMaxSize(), snackbarHostState = snackbarHostState) { _ ->
|
||||
DetailSurface(state, actions, onBack, onEdit)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailSurface(state: ExpenseDetailUiState, actions: ExpenseDetailActions, onBack: () -> Unit, onEdit: (ExpenseId) -> Unit) {
|
||||
val detail = state.detail
|
||||
val me = state.me
|
||||
Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) {
|
||||
if (me == null || state.loading) return@Surface
|
||||
ProvideHousehold(me, state.partner) {
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) {
|
||||
|
||||
+17
-10
@@ -1,6 +1,7 @@
|
||||
package se.hagstedt.kvitt.feature.expense
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -123,28 +124,26 @@ interface ExpenseEditorActions {
|
||||
}
|
||||
|
||||
/**
|
||||
* S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar text for
|
||||
* the opener ("Utgiften sparades" / offline wording for a new expense, null after an edit: back to S-8 without one);
|
||||
* [onDeleted] gets "Utgiften togs bort" and goes back past S-8 to its opener.
|
||||
* S-7, the full-screen expense editor (design-spec §4 S-7, §5). [onSaved] gets the saved id and the snackbar string
|
||||
* resource for the opener (`snackbar_expense_saved` / `snackbar_expense_saved_offline` for a new expense, null after an
|
||||
* edit: back to S-8 without one); [onDeleted] goes back past S-8 to its opener, which shows `snackbar_expense_deleted`.
|
||||
* `:app` resolves the resource while the snackbar shows, as for S-9 and S-10.
|
||||
*/
|
||||
@Composable
|
||||
fun ExpenseEditorScreen(
|
||||
onClose: () -> Unit,
|
||||
onSaved: (ExpenseId, String?) -> Unit,
|
||||
onDeleted: (String) -> Unit,
|
||||
onSaved: (ExpenseId, Int?) -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ExpenseEditorViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val descriptionFocus = remember { FocusRequester() }
|
||||
val saved = stringResource(DesignR.string.snackbar_expense_saved)
|
||||
val savedOffline = stringResource(DesignR.string.snackbar_expense_saved_offline)
|
||||
val deleted = stringResource(DesignR.string.snackbar_expense_deleted)
|
||||
LaunchedEffect(viewModel) {
|
||||
viewModel.eventFlow.collect { event ->
|
||||
when (event) {
|
||||
is EditorEvent.Saved -> onSaved(event.id, if (!event.isNew) null else if (event.offline) savedOffline else saved)
|
||||
EditorEvent.Deleted -> onDeleted(deleted)
|
||||
is EditorEvent.Saved -> onSaved(event.id, savedSnackbar(event.isNew, event.offline))
|
||||
EditorEvent.Deleted -> onDeleted()
|
||||
EditorEvent.Closed -> onClose()
|
||||
EditorEvent.FocusDescription -> descriptionFocus.requestFocus()
|
||||
}
|
||||
@@ -155,6 +154,14 @@ fun ExpenseEditorScreen(
|
||||
ExpenseEditorContent(state, viewModel, modifier, descriptionFocus)
|
||||
}
|
||||
|
||||
/** The snackbar after a save: none after an edit; for a new expense the online or offline wording. */
|
||||
@StringRes
|
||||
internal fun savedSnackbar(isNew: Boolean, offline: Boolean): Int? = when {
|
||||
!isNew -> null
|
||||
offline -> DesignR.string.snackbar_expense_saved_offline
|
||||
else -> DesignR.string.snackbar_expense_saved
|
||||
}
|
||||
|
||||
/** The stateless S-7 layout: app bar, scrolling fields, the keypad, and the overlay of [ExpenseEditorState.overlay]. */
|
||||
@Composable
|
||||
fun ExpenseEditorContent(
|
||||
|
||||
+15
-7
@@ -18,10 +18,12 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.ReadOnlyComposable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -74,13 +76,17 @@ data class SyncStatusActions(
|
||||
val onOpenDateSettings: () -> Unit = {},
|
||||
)
|
||||
|
||||
/** Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5). */
|
||||
/**
|
||||
* Sync status (S-16): status, "Synka nu", pending changes and the changes that were not synced (AC-15.5).
|
||||
* [snackbarHostState] is hosted in this screen's scaffold (a result snackbar after returning here, D-071).
|
||||
*/
|
||||
@Composable
|
||||
fun SyncStatusScreen(
|
||||
onBack: () -> Unit,
|
||||
onOpenExpense: (ExpenseId) -> Unit,
|
||||
onOpenSettlement: (SettlementId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
viewModel: SyncStatusViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.ui.collectAsStateWithLifecycle()
|
||||
@@ -99,16 +105,17 @@ fun SyncStatusScreen(
|
||||
}
|
||||
},
|
||||
)
|
||||
SyncStatusContent(ui, actions, modifier)
|
||||
SyncStatusContent(ui, actions, modifier, snackbarHostState)
|
||||
}
|
||||
|
||||
private const val LOG_TAG = "KvittSyncStatus"
|
||||
|
||||
/** The stateless sync status screen, rendered by [SyncStatusScreen], previews and screenshot tests. */
|
||||
@Composable
|
||||
fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier) {
|
||||
fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Modifier = Modifier, snackbarHostState: SnackbarHostState? = null) {
|
||||
KvittScaffold(
|
||||
modifier = modifier,
|
||||
snackbarHostState = snackbarHostState,
|
||||
topBar = { KvittTopAppBar(stringResource(R.string.home_sync_status_title), navigation = TopBarNavigation.Back, onNavigation = actions.onBack) },
|
||||
) { padding ->
|
||||
val ready = ui as? SyncStatusUi.Ready
|
||||
@@ -246,10 +253,11 @@ private fun PendingGroup(rows: List<LedgerRow>, actions: SyncStatusActions) {
|
||||
private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val clock = LocalKvittClock.current
|
||||
val partnerName = ui.partner?.displayName.orEmpty()
|
||||
// §14.4.6 "<actor>": who made the server's version; the partner when the server had none to name.
|
||||
val actorName = (rejection.actor ?: ui.partner)?.displayName.orEmpty()
|
||||
val settlement = rejection.entityType == EntityType.SETTLEMENT
|
||||
val reason = when (rejection.reason) {
|
||||
SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, partnerName)
|
||||
SyncRejectionReason.STALE -> stringResource(R.string.home_sync_reject_stale, actorName)
|
||||
SyncRejectionReason.DELETED -> stringResource(R.string.home_sync_reject_deleted)
|
||||
SyncRejectionReason.VALIDATION_FAILED -> stringResource(R.string.home_sync_reject_validation)
|
||||
SyncRejectionReason.REFERENCE_NOT_FOUND -> stringResource(R.string.home_sync_reject_reference)
|
||||
@@ -257,8 +265,8 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
|
||||
}
|
||||
val help = when {
|
||||
rejection.reason != SyncRejectionReason.STALE -> null
|
||||
settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, partnerName)
|
||||
else -> stringResource(R.string.home_sync_reject_stale_help, partnerName)
|
||||
settlement -> stringResource(R.string.home_sync_reject_stale_help_payment, actorName)
|
||||
else -> stringResource(R.string.home_sync_reject_stale_help, actorName)
|
||||
}
|
||||
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
|
||||
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
|
||||
|
||||
+6
-4
@@ -21,11 +21,13 @@ sealed interface SyncStatusUi {
|
||||
data object Starting : SyncStatusUi
|
||||
|
||||
/**
|
||||
* @property me the viewer and [partner] the other person: `stale` is explained with the partner's name (§14.4.6;
|
||||
* `Rejection` carries no actor, see the M7b report).
|
||||
* @property failed the last attempt failed: `SyncSummary` has no such field yet, so the ViewModel passes false.
|
||||
* @property me the viewer and [partner] the other person: `stale` names the rejection's actor, or the partner when
|
||||
* it has none (§14.4.6).
|
||||
*/
|
||||
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val failed: Boolean = false) : SyncStatusUi {
|
||||
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?) : SyncStatusUi {
|
||||
/** The last attempt failed while online ("Synkningen misslyckades"); offline wins, as on the pill (§3.6). */
|
||||
val failed: Boolean get() = online && status.summary.lastAttemptFailed
|
||||
|
||||
/** Nothing pending, nothing refused, no clock problem: "Allt är synkat". */
|
||||
val allSynced: Boolean
|
||||
get() = status.pending.isEmpty() && status.rejections.isEmpty() && !status.summary.clockSkew && !status.summary.running && online && !failed
|
||||
|
||||
+6
-7
@@ -23,11 +23,10 @@ fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi
|
||||
vm.ui.value
|
||||
}
|
||||
|
||||
/** Sync status with [scenario]; [failed] stands in for the missing `SyncSummary` field (see the M7b report). */
|
||||
fun syncState(scenario: FakeScenario, failed: Boolean = false): SyncStatusUi = withMain {
|
||||
/** Sync status with [scenario]. */
|
||||
fun syncState(scenario: FakeScenario): SyncStatusUi = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
val ui = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
|
||||
if (failed && ui is SyncStatusUi.Ready) ui.copy(failed = true) else ui
|
||||
SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
|
||||
}
|
||||
|
||||
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
|
||||
@@ -54,7 +53,7 @@ val homeStates: List<ScreenState> = listOf(
|
||||
sync("S-16-sync-rejections", HomeScenarios.allRejections),
|
||||
sync("S-16-sync-clock", HomeScenarios.clockSkew),
|
||||
sync("S-16-sync-running", HomeScenarios.syncing),
|
||||
sync("S-16-sync-error", HomeScenarios.pending, failed = true),
|
||||
sync("S-16-sync-error", HomeScenarios.pending.copy(lastAttemptFailed = true)),
|
||||
sync("S-16-sync-offline", HomeScenarios.syncOffline),
|
||||
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
|
||||
)
|
||||
@@ -64,8 +63,8 @@ private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = fals
|
||||
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
|
||||
}
|
||||
|
||||
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, failed: Boolean = false): ScreenState {
|
||||
val ui = syncState(scenario, failed)
|
||||
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false): ScreenState {
|
||||
val ui = syncState(scenario)
|
||||
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -12,6 +12,7 @@ import se.hagstedt.kvitt.fakes.home.HomeScenarios
|
||||
import se.hagstedt.kvitt.model.DefaultCategoryKey
|
||||
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||
import se.hagstedt.kvitt.model.UpdateRepository
|
||||
import se.hagstedt.kvitt.model.UpdateCheck
|
||||
import se.hagstedt.kvitt.model.UpdateState
|
||||
|
||||
/** Records `install()` calls; the update state is set by the test (no mocking library, CLAUDE.md). */
|
||||
@@ -19,7 +20,7 @@ private class RecordingUpdates(initial: UpdateState) : UpdateRepository {
|
||||
val flow = MutableStateFlow(initial)
|
||||
var installs = 0
|
||||
override val state: Flow<UpdateState> = flow
|
||||
override suspend fun check() = Unit
|
||||
override suspend fun check(): UpdateCheck = UpdateCheck.UpToDate
|
||||
override suspend fun install() {
|
||||
installs += 1
|
||||
}
|
||||
|
||||
+31
-12
@@ -6,13 +6,16 @@ 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?)
|
||||
/**
|
||||
* What "Klistra in" found on the clipboard: a normalised 12-character code and, from a link, the server and the
|
||||
* inviting admin's display name ([adminName], the optional `from` parameter of E-12's link; older links have none).
|
||||
*/
|
||||
data class PastedInvite(val code: String, val serverUrl: String?, val adminName: String? = null)
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Reads pasted text on S-1 (design-spec S-1 "Klistra in") and the links `:app` opens: an invite link
|
||||
* `kvitt://invite?server=…&code=…[&from=…]`, a landing link `https://<server>/i/<CODE>[?from=…]` (AC-2.5, AC-2.6), or a
|
||||
* bare code in any spelling §8.3 normalises. Anything else is null.
|
||||
*/
|
||||
object InviteLink {
|
||||
fun parse(text: String): PastedInvite? {
|
||||
@@ -31,13 +34,9 @@ object InviteLink {
|
||||
|
||||
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 params = params(uri.rawQuery ?: return null)
|
||||
val code = params["code"]?.let(::code) ?: return null
|
||||
return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() })
|
||||
return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() }, adminName(params[FROM]))
|
||||
}
|
||||
|
||||
private fun fromLandingLink(uri: URI): PastedInvite? {
|
||||
@@ -46,9 +45,29 @@ object InviteLink {
|
||||
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")
|
||||
return PastedInvite(code, "${uri.scheme.lowercase()}://${uri.host}$port", adminName(uri.rawQuery?.let(::params)?.get(FROM)))
|
||||
}
|
||||
|
||||
/** The first value of each key; a value that is not valid percent-encoding is dropped. */
|
||||
private fun params(query: String): Map<String, String> = query.split('&').mapNotNull { pair ->
|
||||
val key = pair.substringBefore('=', "")
|
||||
val value = try {
|
||||
URLDecoder.decode(pair.substringAfter('='), Charsets.UTF_8)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
if (key.isEmpty() || value == null) null else key to value
|
||||
}.distinctBy { it.first }.toMap()
|
||||
|
||||
/** A display name of §9.6 (1 to 40 code points after trimming, no control characters), else null. */
|
||||
private fun adminName(value: String?): String? {
|
||||
val name = value?.trim() ?: return null
|
||||
return name.takeIf { it.codePointCount(0, it.length) in 1..NAME_MAX && it.none(Char::isISOControl) }
|
||||
}
|
||||
|
||||
private const val FROM = "from"
|
||||
private const val NAME_MAX = 40
|
||||
|
||||
/** 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() }
|
||||
|
||||
+9
-10
@@ -62,8 +62,8 @@ data class PairingActions(
|
||||
)
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* S-1 Pairing (design-spec S-1). [initialServerUrl], [initialCode] and [initialAdminName] come from an invite link that
|
||||
* `:app` parsed (AC-2.6); [onPaired] receives where to go next (S-2 after a partner invite, AC-2.10; else Home).
|
||||
*/
|
||||
@Composable
|
||||
fun PairingScreen(
|
||||
@@ -71,13 +71,14 @@ fun PairingScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
initialServerUrl: String? = null,
|
||||
initialCode: String? = null,
|
||||
initialAdminName: 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(initialServerUrl, initialCode, initialAdminName) { viewModel.prefill(initialServerUrl, initialCode, initialAdminName) }
|
||||
LaunchedEffect(state.done) {
|
||||
state.done?.let {
|
||||
onPaired(it)
|
||||
@@ -181,7 +182,7 @@ fun PairingContent(
|
||||
readOnly = state.pairing,
|
||||
)
|
||||
}
|
||||
HelpLine()
|
||||
HelpLine(state.adminName)
|
||||
val serverError = when (state.error) {
|
||||
PairingError.Network -> stringResource(R.string.onboarding_error_network)
|
||||
PairingError.HttpsRequired -> stringResource(R.string.onboarding_error_https)
|
||||
@@ -245,19 +246,17 @@ private fun Brand(mode: PairingMode) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** Where the code comes from: from the admin named in the invite link, else the generic line (AC-2.6). */
|
||||
@Composable
|
||||
private fun HelpLine() {
|
||||
private fun HelpLine(adminName: String?) {
|
||||
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2),
|
||||
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
|
||||
) {
|
||||
KvittIconImage(KvittIcon.Info, null, size = KvittDimens.helpIcon, tint = muted)
|
||||
Text(stringResource(R.string.onboarding_code_help_generic), style = MaterialTheme.typography.bodyMedium, color = muted)
|
||||
val text = adminName?.let { stringResource(R.string.onboarding_code_help, it) } ?: stringResource(R.string.onboarding_code_help_generic)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = muted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -11,7 +11,7 @@ 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.InviteKind
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import javax.inject.Inject
|
||||
@@ -38,17 +38,21 @@ enum class PairingError {
|
||||
|
||||
/** Where S-1 goes after a successful pairing. */
|
||||
enum class PairingDone {
|
||||
/** A `member` on a first pairing confirms the profile first (AC-2.10). */
|
||||
/** A `partner` pairing confirms the profile first (AC-2.10); a device invite does not, even for the member. */
|
||||
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. */
|
||||
/**
|
||||
* The state of S-1. [code] is normalised (§8.3), at most 12 characters, without hyphens. [adminName] is the inviting
|
||||
* admin's name from the link, for the help line (`onboarding_code_help`, AC-2.6); null without one.
|
||||
*/
|
||||
data class PairingUiState(
|
||||
val mode: PairingMode = PairingMode.First,
|
||||
val code: String = "",
|
||||
val adminName: String? = null,
|
||||
val name: String = "",
|
||||
val serverUrl: String = RELEASE_SERVER_URL,
|
||||
val serverExpanded: Boolean = false,
|
||||
@@ -99,13 +103,14 @@ class PairingViewModel internal constructor(
|
||||
* 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?) {
|
||||
fun prefill(serverUrl: String?, code: String?, adminName: String? = null) {
|
||||
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,
|
||||
adminName = adminName ?: s.adminName,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
)
|
||||
@@ -134,6 +139,7 @@ class PairingViewModel internal constructor(
|
||||
val server = invite.serverUrl
|
||||
s.copy(
|
||||
code = invite.code,
|
||||
adminName = invite.adminName ?: s.adminName,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
error = null,
|
||||
@@ -159,7 +165,7 @@ class PairingViewModel internal constructor(
|
||||
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,
|
||||
done = if (result.value.inviteKind == InviteKind.PARTNER) PairingDone.ProfileStep else PairingDone.Home,
|
||||
)
|
||||
OnlineResult.Offline -> current.copy(pairing = false, error = PairingError.Network, serverExpanded = true)
|
||||
is OnlineResult.Failed -> {
|
||||
|
||||
+15
@@ -29,6 +29,21 @@ class InviteLinkTest {
|
||||
assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun linksMayNameTheAdmin_andABadNameIsDropped() {
|
||||
assertEquals(
|
||||
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Jacob"),
|
||||
InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA&from=Jacob"),
|
||||
)
|
||||
assertEquals(
|
||||
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Åsa Berg"),
|
||||
InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=%C3%85sa+Berg"),
|
||||
)
|
||||
for (bad in listOf("", "%20", "a%0Ab", "A".repeat(41))) {
|
||||
assertEquals(bad, PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=$bad"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLandingLinkGivesCodeAndServer() {
|
||||
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA"))
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ 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
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PairingViewModelTest {
|
||||
@@ -68,6 +69,16 @@ class PairingViewModelTest {
|
||||
assertEquals(PairingDone.ProfileStep, vm.state.value.done)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin() {
|
||||
val (_, vm) = vm(OnboardingScenarios.unpairedPartner.copy(pairingInviteKind = InviteKind.DEVICE))
|
||||
vm.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Jacob")
|
||||
assertEquals("Jacob", vm.state.value.adminName)
|
||||
vm.onNameChange("Anna")
|
||||
vm.pair("Galaxy S26+")
|
||||
assertEquals(PairingDone.Home, vm.state.value.done)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAdminGoesHomeAndTheAgainVariantSendsNoName() {
|
||||
val (_, admin) = vm(OnboardingScenarios.unpaired)
|
||||
|
||||
+8
@@ -76,6 +76,14 @@ object OnboardingStates {
|
||||
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) }),
|
||||
// AC-2.6: a link that names the inviting admin fills the help line with the name.
|
||||
ScreenState(
|
||||
"s1_pairing_link_admin",
|
||||
render = pairing(OnboardingScenarios.unpaired) {
|
||||
prefill(RELEASE_SERVER_URL, CODE, "Jacob")
|
||||
onNameChange("Anna")
|
||||
},
|
||||
),
|
||||
ScreenState("s1_pairing_loading", render = pairing(OnboardingScenarios.unpaired, CallOutcome.Hang) { fillFromLink(); pair("Galaxy S26") }),
|
||||
ScreenState(
|
||||
"s1_pairing_error",
|
||||
|
||||
+9
-6
@@ -7,7 +7,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -16,6 +15,7 @@ import se.hagstedt.kvitt.feature.settings.common.UiMessage
|
||||
import se.hagstedt.kvitt.model.AboutRepository
|
||||
import se.hagstedt.kvitt.model.AboutState
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.UpdateCheck
|
||||
import se.hagstedt.kvitt.model.UpdateRepository
|
||||
import se.hagstedt.kvitt.model.UpdateState
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
@@ -37,8 +37,8 @@ class AboutViewModel @Inject constructor(
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, null)
|
||||
|
||||
/**
|
||||
* "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner, or "Du har den senaste
|
||||
* versionen". `check()` reports no failure (§14.2.4), so a check that finds nothing reads as up to date.
|
||||
* "Sök efter uppdatering" (online, FR-16): progress in the button; then the banner (Available), "Du har den
|
||||
* senaste versionen" (UpToDate, AC-16.6) or "Kunde inte nå servern" (Failed).
|
||||
*/
|
||||
fun check() {
|
||||
val s = state.value ?: return
|
||||
@@ -49,9 +49,12 @@ class AboutViewModel @Inject constructor(
|
||||
}
|
||||
local.update { it.copy(checking = true, bannerHidden = false) }
|
||||
viewModelScope.launch {
|
||||
update.check()
|
||||
val found = update.state.first()
|
||||
local.update { it.copy(checking = false, message = if (found is UpdateState.None) UiMessage(R.string.settings_about_up_to_date) else it.message) }
|
||||
val message = when (update.check()) {
|
||||
UpdateCheck.UpToDate -> UiMessage(R.string.settings_about_up_to_date)
|
||||
UpdateCheck.Failed -> UiMessage(DesignR.string.common_could_not_reach_server)
|
||||
is UpdateCheck.Available -> null
|
||||
}
|
||||
local.update { it.copy(checking = false, message = message ?: it.message) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ private fun DeviceRow(item: SegmentedItemScope, device: Device, revocable: Boole
|
||||
val meta = listOfNotNull(
|
||||
stringResource(R.string.settings_devices_paired, listDate(device.pairedAt.take(DATE_LENGTH))),
|
||||
stringResource(R.string.settings_devices_last_seen, relativeTime(device.lastSeenAt)),
|
||||
device.appVersionCode?.let { stringResource(R.string.settings_devices_app_version, it.toString()) },
|
||||
(device.appVersionName ?: device.appVersionCode?.toString())?.let { stringResource(R.string.settings_devices_app_version, it) },
|
||||
).joinToString(dot)
|
||||
Row(
|
||||
Modifier
|
||||
|
||||
+8
-2
@@ -225,11 +225,17 @@ class RecurringEditorViewModel @Inject constructor(
|
||||
local.update { it.copy(triedSave = true) }
|
||||
val s = state.value ?: return
|
||||
if (!s.canSave) return
|
||||
val template = s.form.template(s.original, s.options.me.id, s.options.partner.id, s.options.today)
|
||||
val me = s.options.me.id
|
||||
val partner = s.options.partner.id
|
||||
focus(EditorFocus.None)
|
||||
local.update { it.copy(busy = true) }
|
||||
viewModelScope.launch {
|
||||
val result: OnlineResult<*> = if (s.original == null) recurring.create(template) else recurring.update(template)
|
||||
val original = s.original
|
||||
val result: OnlineResult<*> = if (original == null) {
|
||||
recurring.create(s.form.newTemplate(me, partner, s.options.today))
|
||||
} else {
|
||||
recurring.update(s.form.template(original, me, partner, s.options.today))
|
||||
}
|
||||
local.update { it.copy(busy = false) }
|
||||
when {
|
||||
result is OnlineResult.Ok -> local.update { it.copy(done = true) }
|
||||
|
||||
+15
-4
@@ -7,9 +7,9 @@ import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.ExpenseDraft
|
||||
import se.hagstedt.kvitt.model.ExpenseEditorOptions
|
||||
import se.hagstedt.kvitt.model.NewRecurringTemplate
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.model.TemplateId
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
|
||||
/** Which keypad field has focus (design-spec §3.7: the keypad drives the amount, the exact share and the percents). */
|
||||
@@ -80,16 +80,27 @@ data class RecurringForm(
|
||||
)
|
||||
}
|
||||
|
||||
fun template(original: RecurringTemplate?, me: UserId, partner: UserId, today: String): RecurringTemplate = RecurringTemplate(
|
||||
id = original?.id ?: TemplateId(""),
|
||||
/** A new template for `RecurringRepository.create`, which mints the id (E-28). */
|
||||
fun newTemplate(me: UserId, partner: UserId, today: String): NewRecurringTemplate = NewRecurringTemplate(
|
||||
draft = draft(me, partner, today),
|
||||
dayOfMonth = dayOfMonth ?: 0,
|
||||
startPeriod = startPeriod(today),
|
||||
endPeriod = end,
|
||||
paused = paused,
|
||||
nextOccurrence = original?.nextOccurrence,
|
||||
)
|
||||
|
||||
/** [original] with the form's values, for `RecurringRepository.update`. */
|
||||
fun template(original: RecurringTemplate, me: UserId, partner: UserId, today: String): RecurringTemplate {
|
||||
val fields = newTemplate(me, partner, today)
|
||||
return original.copy(
|
||||
draft = fields.draft,
|
||||
dayOfMonth = fields.dayOfMonth,
|
||||
startPeriod = fields.startPeriod,
|
||||
endPeriod = fields.endPeriod,
|
||||
paused = fields.paused,
|
||||
)
|
||||
}
|
||||
|
||||
/** One keypad key on the focused field; a rejected key leaves the form unchanged. */
|
||||
fun press(key: AmountKey): RecurringForm = when (focus) {
|
||||
EditorFocus.Amount -> amount.press(key)?.let { copy(amount = it) } ?: this
|
||||
|
||||
+4
@@ -104,6 +104,10 @@ class DevicesInvitesAboutScreenshotTest(private val state: String) {
|
||||
about("online", SettingsScenarios.loaded),
|
||||
about("offline", SettingsScenarios.offline),
|
||||
about("check_up_to_date", SettingsScenarios.loaded) { _, vm -> vm.check() },
|
||||
about("check_failed", SettingsScenarios.loaded) { r, vm ->
|
||||
r.checkFails = true
|
||||
vm.check()
|
||||
},
|
||||
about("check_update_found", SettingsScenarios.loaded) { r, vm ->
|
||||
r.afterCheck = UpdateState.Available(SettingsScenarios.update)
|
||||
vm.check()
|
||||
|
||||
+18
-15
@@ -17,6 +17,7 @@ import se.hagstedt.kvitt.model.AccountRepository
|
||||
import se.hagstedt.kvitt.model.AllocationLine
|
||||
import se.hagstedt.kvitt.model.FieldError
|
||||
import se.hagstedt.kvitt.model.HomeRepository
|
||||
import se.hagstedt.kvitt.model.UntouchedAccount
|
||||
import se.hagstedt.kvitt.model.SaveResult
|
||||
import se.hagstedt.kvitt.model.SettleRequest
|
||||
import se.hagstedt.kvitt.model.SettleState
|
||||
@@ -48,8 +49,8 @@ data class SettleRow(val line: AllocationLine, val input: AmountInput)
|
||||
|
||||
/**
|
||||
* Everything S-9 shows. [settle] fields come from `SettlementRepository.start`/`recompute` (§14.2.4); the feature never
|
||||
* computes the proposal, the remaining amount, the warnings or [canConfirm] itself. [viewerNets] are the viewer's
|
||||
* account balances from Home, used only for the §9.4 info line about accounts left untouched.
|
||||
* computes the proposal, the remaining amount, the warnings or [canConfirm] itself. [untouchedAccounts] are the
|
||||
* repository's accounts left untouched (`SettleState.untouched`, D-074), for the §9.4 info line.
|
||||
*/
|
||||
data class SettleUiState(
|
||||
val loading: Boolean = true,
|
||||
@@ -67,7 +68,7 @@ data class SettleUiState(
|
||||
val rowsEdited: Boolean = false,
|
||||
val fixedAccount: Account? = null,
|
||||
val accounts: List<Account> = emptyList(),
|
||||
val viewerNets: Map<AccountId, Long> = emptyMap(),
|
||||
val untouchedAccounts: List<UntouchedAccount> = emptyList(),
|
||||
val errors: List<FieldError> = emptyList(),
|
||||
val dialog: SettleDialog? = null,
|
||||
val dirty: Boolean = false,
|
||||
@@ -87,18 +88,15 @@ data class SettleUiState(
|
||||
val showRemaining: Boolean get() = mode != SettleMode.Account && rows.isNotEmpty()
|
||||
|
||||
/**
|
||||
* §9.4 Consequences: accounts where the payer is owed and that are not in the rows (global variant). The viewer's
|
||||
* net sign of each says who owes there; all of them have the same sign because the payer is owed in each.
|
||||
* §9.4 Consequences: the repository's accounts where the payer is owed and that are not in the rows (global
|
||||
* variant), each with the viewer's net there; all of them have the same sign because the payer is owed in each.
|
||||
*/
|
||||
val untouched: List<Pair<Account, Long>>
|
||||
get() {
|
||||
val payer = payer ?: return emptyList()
|
||||
if (mode == SettleMode.Account || rows.isEmpty()) return emptyList()
|
||||
return accounts.mapNotNull { account ->
|
||||
val viewerNet = viewerNets[account.id] ?: 0L
|
||||
val payerNet = if (payer.id == me?.id) viewerNet else -viewerNet
|
||||
account.takeIf { payerNet > 0 && rows.none { it.line.account.id == account.id } }?.let { it to viewerNet }
|
||||
}
|
||||
return untouchedAccounts.filter { u -> rows.none { it.line.account.id == u.account.id } }
|
||||
.map { u -> u.account to if (payer.id == me?.id) u.payerNetOre else -u.payerNetOre }
|
||||
}
|
||||
|
||||
val overSettled: List<SettleRow> get() = rows.filter { it.line.overSettles }
|
||||
@@ -152,10 +150,7 @@ class SettleViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
home.home().collect { h ->
|
||||
val nets = (h.active + h.archived).associate { it.account.id to it.netOre }
|
||||
mutable.update { it.copy(me = h.me, partner = h.partner, viewerNets = nets) }
|
||||
}
|
||||
home.home().collect { h -> mutable.update { it.copy(me = h.me, partner = h.partner) } }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val start = settlements.start(editing, if (editing == null) fixedAccountId else null)
|
||||
@@ -271,5 +266,13 @@ internal fun SettleUiState.withResult(result: SettleState, propose: Boolean): Se
|
||||
rows.map { row -> SettleRow(result.lines.firstOrNull { it.account.id == row.line.account.id } ?: row.line, row.input) }
|
||||
}
|
||||
val amount = if (loading) AmountText.of(result.amountOre) else amount
|
||||
return copy(payer = result.payer, recipient = result.recipient, amount = amount, rows = rows, remainingOre = result.remainingOre, canConfirm = result.canConfirm)
|
||||
return copy(
|
||||
payer = result.payer,
|
||||
recipient = result.recipient,
|
||||
amount = amount,
|
||||
rows = rows,
|
||||
remainingOre = result.remainingOre,
|
||||
canConfirm = result.canConfirm,
|
||||
untouchedAccounts = result.untouched,
|
||||
)
|
||||
}
|
||||
|
||||
+7
-1
@@ -13,10 +13,12 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
@@ -68,6 +70,7 @@ import kotlin.time.Instant
|
||||
/**
|
||||
* S-10 Settlement detail (design-spec S-10) for [SettleArgs.SETTLEMENT_ID]. [onEdit] opens S-9 editing; after a
|
||||
* confirmed delete [onDeleted] goes back, where "Betalningen togs bort" (`snackbar_payment_deleted`) is shown.
|
||||
* [snackbarHostState] is hosted in this screen's scaffold ("Betalningen sparades" after an edit, D-071).
|
||||
*/
|
||||
@Composable
|
||||
fun SettlementDetailScreen(
|
||||
@@ -75,11 +78,12 @@ fun SettlementDetailScreen(
|
||||
onEdit: (SettlementId) -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() },
|
||||
viewModel: SettlementDetailViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(state.deletedByMe) { if (state.deletedByMe) onDeleted() }
|
||||
SettlementDetailContent(state, viewModel, onBack, onEdit, modifier)
|
||||
SettlementDetailContent(state, viewModel, onBack, onEdit, modifier, snackbarHostState = snackbarHostState)
|
||||
}
|
||||
|
||||
/** The stateless S-10, rendered by [SettlementDetailScreen], previews and screenshot tests. */
|
||||
@@ -91,12 +95,14 @@ fun SettlementDetailContent(
|
||||
onEdit: (SettlementId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
overlayHost: OverlayHost = WindowOverlayHost,
|
||||
snackbarHostState: SnackbarHostState? = null,
|
||||
) {
|
||||
val me = state.me
|
||||
val detail = state.detail
|
||||
val body: @Composable () -> Unit = {
|
||||
KvittScaffold(
|
||||
modifier.fillMaxSize(),
|
||||
snackbarHostState = snackbarHostState,
|
||||
topBar = {
|
||||
KvittTopAppBar(stringResource(R.string.settle_detail_title), navigation = TopBarNavigation.Back, onNavigation = onBack) {
|
||||
if (state.actions && detail != null) {
|
||||
|
||||
Reference in New Issue
Block a user