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