Coverage Summary for Class: SyncScreenKt (com.georgeci.moneysurfer.feature.settings.sync)

Class Class, % Method, % Branch, % Line, % Instruction, %
SyncScreenKt 100% (1/1) 89.3% (25/28) 71% (115/162) 95.3% (282/296) 92.2% (3348/3633)


 package com.georgeci.moneysurfer.feature.settings.sync
 
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material3.Scaffold
 import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.testTag
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import com.georgeci.moneysurfer.sync.api.LastSyncOutcome
 import com.georgeci.moneysurfer.sync.api.SyncError
 import com.georgeci.moneysurfer.sync.api.SyncStep
 import com.georgeci.moneysurfer.sync.repository.PendingMutation
 import com.georgeci.moneysurfer.sync.repository.SyncMeta
 import com.georgeci.moneysurfer.uikit.components.SurferConfirmDialog
 import com.georgeci.moneysurfer.uikit.components.base.SurferPaneTopBar
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsChevron
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsGroup
 import com.georgeci.moneysurfer.uikit.components.settings.SurferSettingsRow
 import com.georgeci.moneysurfer.uikit.components.settings.SurferStatusHeroCard
 import com.georgeci.moneysurfer.uikit.components.settings.SurferStatusHeroTone
 import com.georgeci.moneysurfer.uikit.icons.SurferIcons
 import com.georgeci.moneysurfer.uikit.modifier.surferContentContainer
 import com.georgeci.moneysurfer.uikit.modifier.surferSafeInsets
 import com.georgeci.moneysurfer.uikit.theme.AppTheme
 import com.georgeci.moneysurfer.utils.HandleSideEffect
 import kotlinx.datetime.TimeZone
 import kotlinx.datetime.toLocalDateTime
 import moneysurfer.feature.settings.generated.resources.Res
 import moneysurfer.feature.settings.generated.resources.settings_sync_action_force
 import moneysurfer.feature.settings.generated.resources.settings_sync_action_force_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_action_reset_cursors
 import moneysurfer.feature.settings.generated.resources.settings_sync_action_reset_cursors_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_actions_disabled_footnote
 import moneysurfer.feature.settings.generated.resources.settings_sync_cursor_attempt
 import moneysurfer.feature.settings.generated.resources.settings_sync_cursor_pulled
 import moneysurfer.feature.settings.generated.resources.settings_sync_cursor_success
 import moneysurfer.feature.settings.generated.resources.settings_sync_cursors_empty_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_cursors_empty_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_cursors_footnote
 import moneysurfer.feature.settings.generated.resources.settings_sync_done
 import moneysurfer.feature.settings.generated.resources.settings_sync_error_auth
 import moneysurfer.feature.settings.generated.resources.settings_sync_error_cancelled
 import moneysurfer.feature.settings.generated.resources.settings_sync_error_network
 import moneysurfer.feature.settings.generated.resources.settings_sync_error_permission
 import moneysurfer.feature.settings.generated.resources.settings_sync_error_storage
 import moneysurfer.feature.settings.generated.resources.settings_sync_error_unknown
 import moneysurfer.feature.settings.generated.resources.settings_sync_failed
 import moneysurfer.feature.settings.generated.resources.settings_sync_hero_idle_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_hero_progress_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_in_progress_with_step
 import moneysurfer.feature.settings.generated.resources.settings_sync_last_at
 import moneysurfer.feature.settings.generated.resources.settings_sync_last_cancelled_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_last_counts
 import moneysurfer.feature.settings.generated.resources.settings_sync_last_failed_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_last_none_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_last_none_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_last_success_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_idle_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_idle_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_queued_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_queued_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_reasons
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_request
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_running_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_scope
 import moneysurfer.feature.settings.generated.resources.settings_sync_live_step
 import moneysurfer.feature.settings.generated.resources.settings_sync_no_workspace
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_attempts
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_empty_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_empty_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_error
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_footnote
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_row_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_scope
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_scope_root
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_truncated
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_unknown_supporting
 import moneysurfer.feature.settings.generated.resources.settings_sync_outbox_unknown_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_reset_cursors_confirm_cancel
 import moneysurfer.feature.settings.generated.resources.settings_sync_reset_cursors_confirm_confirm
 import moneysurfer.feature.settings.generated.resources.settings_sync_reset_cursors_confirm_detail
 import moneysurfer.feature.settings.generated.resources.settings_sync_reset_cursors_confirm_message
 import moneysurfer.feature.settings.generated.resources.settings_sync_reset_cursors_confirm_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_section_actions
 import moneysurfer.feature.settings.generated.resources.settings_sync_section_cursors
 import moneysurfer.feature.settings.generated.resources.settings_sync_section_last
 import moneysurfer.feature.settings.generated.resources.settings_sync_section_live
 import moneysurfer.feature.settings.generated.resources.settings_sync_section_outbox
 import moneysurfer.feature.settings.generated.resources.settings_sync_section_outbox_unknown
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_cancelled
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_completed
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_failed
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_pulling
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_pulling_collection
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_recalculating
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_started
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_uploading
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_uploading_entity
 import moneysurfer.feature.settings.generated.resources.settings_sync_step_waiting_network
 import moneysurfer.feature.settings.generated.resources.settings_sync_title
 import moneysurfer.feature.settings.generated.resources.settings_sync_value_never
 import moneysurfer.feature.settings.generated.resources.settings_sync_workspace_scope
 import org.jetbrains.compose.resources.stringResource
 import org.koin.compose.viewmodel.koinViewModel
 import kotlin.time.Instant
 import com.georgeci.moneysurfer.sync.api.SyncState as LiveSyncState
 
 /** Stable selectors for the sync panel — see docs/testing/testing-strategy.md. */
 object SyncTestTags {
     const val Hero = "sync:hero"
     const val Live = "sync:live"
     const val LastOutcome = "sync:lastOutcome"
     const val Outbox = "sync:outbox"
     const val OutboxEmpty = "sync:outbox:empty"
     const val OutboxUnknown = "sync:outbox:unknown"
     const val Cursors = "sync:cursors"
     const val CursorsEmpty = "sync:cursors:empty"
     const val ForceSyncRow = "sync:action:force"
     const val ResetCursorsRow = "sync:action:resetCursors"
     const val ResetCursorsDialog = "sync:dialog:resetCursors"
 
     fun outboxRow(id: String): String = "sync:outbox:row:$id"
     fun cursorRow(collection: String): String = "sync:cursors:row:$collection"
 }
 
 @Composable
 fun SyncScreen(
     onNavigateBack: () -> Unit,
     viewModel: SyncViewModel = koinViewModel(),
 ) {
     val state by viewModel.collectAsStateWithLifecycle()
 
     viewModel.HandleSideEffect { effect ->
         when (effect) {
             SyncEffect.NavigateBack -> onNavigateBack()
         }
     }
 
     SyncContent(state = state, onEvent = viewModel::onEvent)
 }
 
 /**
  * Stateless content, public so `:composeApp`'s desktop UI tests can mount it — as with the other
  * screens.
  */
 @Composable
 fun SyncContent(
     state: SyncState,
     onEvent: (SyncEvent) -> Unit,
 ) {
     Scaffold(
         modifier = Modifier.surferSafeInsets(),
         containerColor = AppTheme.materialColors.surface,
         topBar = {
             SurferPaneTopBar(
                 title = stringResource(Res.string.settings_sync_title),
                 onBack = { onEvent(SyncEvent.OnBackClick) },
                 colors = TopAppBarDefaults.topAppBarColors(
                     containerColor = AppTheme.materialColors.surface,
                     titleContentColor = AppTheme.materialColors.onSurface,
                 ),
             )
         },
     ) { padding ->
         Column(
             modifier = Modifier
                 .fillMaxSize()
                 .surferContentContainer()
                 .padding(top = padding.calculateTopPadding())
                 .verticalScroll(rememberScrollState()),
         ) {
             Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
                 SyncStatusHero(state.syncStatus)
             }
 
             LiveStateSection(state)
             LastOutcomeSection(state.lastOutcome)
             OutboxSection(state)
             CursorsSection(state.cursors)
             ActionsSection(state = state, onEvent = onEvent)
 
             Spacer(Modifier.height(padding.calculateBottomPadding() + 24.dp))
         }
     }
 
     if (state.showResetCursorsConfirm) {
         SurferConfirmDialog(
             title = stringResource(Res.string.settings_sync_reset_cursors_confirm_title),
             message = stringResource(Res.string.settings_sync_reset_cursors_confirm_message),
             detail = stringResource(Res.string.settings_sync_reset_cursors_confirm_detail),
             confirmLabel = stringResource(Res.string.settings_sync_reset_cursors_confirm_confirm),
             cancelLabel = stringResource(Res.string.settings_sync_reset_cursors_confirm_cancel),
             icon = SurferIcons.Restore,
             destructive = true,
             onConfirm = { onEvent(SyncEvent.OnResetCursorsConfirmed) },
             onDismiss = { onEvent(SyncEvent.OnResetCursorsDismissed) },
             modifier = Modifier.testTag(SyncTestTags.ResetCursorsDialog),
         )
     }
 }
 
 @Composable
 private fun SyncStatusHero(status: SyncStatus) {
     when (status) {
         SyncStatus.Idle -> SurferStatusHeroCard(
             title = stringResource(Res.string.settings_sync_hero_idle_title),
             supporting = stringResource(Res.string.settings_sync_live_idle_supporting),
             icon = SurferIcons.Sync,
             tone = SurferStatusHeroTone.Tertiary,
             modifier = Modifier.testTag(SyncTestTags.Hero),
         )
         SyncStatus.NoWorkspace -> SurferStatusHeroCard(
             title = stringResource(Res.string.settings_sync_no_workspace),
             icon = SurferIcons.Cloud,
             tone = SurferStatusHeroTone.Secondary,
             modifier = Modifier.testTag(SyncTestTags.Hero),
         )
         is SyncStatus.Queued -> SurferStatusHeroCard(
             title = stringResource(Res.string.settings_sync_hero_progress_title),
             supporting = stringResource(Res.string.settings_sync_live_queued_supporting, status.count),
             icon = SurferIcons.Sync,
             tone = SurferStatusHeroTone.Primary,
             modifier = Modifier.testTag(SyncTestTags.Hero),
         )
         is SyncStatus.Running -> SurferStatusHeroCard(
             title = stringResource(Res.string.settings_sync_hero_progress_title),
             supporting = stringResource(Res.string.settings_sync_in_progress_with_step, syncStepLabel(status.step)),
             icon = SurferIcons.Sync,
             tone = SurferStatusHeroTone.Primary,
             modifier = Modifier.testTag(SyncTestTags.Hero),
         )
         is SyncStatus.Done -> SurferStatusHeroCard(
             title = stringResource(Res.string.settings_sync_hero_idle_title),
             supporting = stringResource(
                 Res.string.settings_sync_done,
                 status.summary.uploadedCount,
                 status.summary.downloadedCount,
             ),
             icon = SurferIcons.Sync,
             tone = SurferStatusHeroTone.Tertiary,
             modifier = Modifier.testTag(SyncTestTags.Hero),
         )
         is SyncStatus.Failed -> SurferStatusHeroCard(
             title = stringResource(Res.string.settings_sync_failed, syncErrorMessage(status.error)),
             icon = SurferIcons.Sync,
             tone = SurferStatusHeroTone.Secondary,
             modifier = Modifier.testTag(SyncTestTags.Hero),
         )
     }
 }
 
 /**
  * The coordinator's own `state` — live activity only. `Running` spells out the merged request
  * (id, reasons, scope) because a sync the user did not ask for is the usual explanation for a
  * "Force sync" that appears to do nothing: it was merged into the one already in flight.
  */
 @Composable
 private fun LiveStateSection(state: SyncState) {
     val title: String
     val details: List<String>
     when (val live = state.live) {
         is LiveSyncState.Queued -> {
             title = stringResource(Res.string.settings_sync_live_queued_title)
             details = listOf(stringResource(Res.string.settings_sync_live_queued_supporting, live.count))
         }
         is LiveSyncState.Running -> {
             title = stringResource(Res.string.settings_sync_live_running_title)
             details = listOf(
                 stringResource(Res.string.settings_sync_live_request, live.requestId.value),
                 stringResource(Res.string.settings_sync_live_reasons, live.reasons.joinToString { it.name }),
                 stringResource(Res.string.settings_sync_live_scope, live.scope.name),
                 stringResource(Res.string.settings_sync_live_step, syncStepLabel(live.currentStep)),
             )
         }
         LiveSyncState.Idle -> {
             title = stringResource(Res.string.settings_sync_live_idle_title)
             details = listOf(stringResource(Res.string.settings_sync_live_idle_supporting))
         }
     }
     val workspaceId = state.workspaceId?.value
 
     SurferSettingsGroup(title = stringResource(Res.string.settings_sync_section_live)) {
         SurferSettingsRow(
             icon = SurferIcons.Sync,
             title = title,
             supporting = {
                 DetailLines(
                     if (workspaceId == null) {
                         details
                     } else {
                         details + stringResource(Res.string.settings_sync_workspace_scope, workspaceId)
                     },
                 )
             },
             multiline = true,
             modifier = Modifier.testTag(SyncTestTags.Live),
         )
     }
 }
 
 /**
  * The persisted terminal outcome, which the live state deliberately does not carry (FAQ №10).
  * Conflicts and recalculations are shown next to the transfer counts because a pull that
  * "downloaded 40" and resolved 40 conflicts kept none of them.
  */
 @Composable
 private fun LastOutcomeSection(outcome: LastSyncOutcome) {
     val title: String
     val details: List<String>
     when (outcome) {
         LastSyncOutcome.None -> {
             title = stringResource(Res.string.settings_sync_last_none_title)
             details = listOf(stringResource(Res.string.settings_sync_last_none_supporting))
         }
         is LastSyncOutcome.Success -> {
             title = stringResource(Res.string.settings_sync_last_success_title)
             details = listOf(
                 stringResource(
                     Res.string.settings_sync_last_counts,
                     outcome.summary.uploadedCount,
                     outcome.summary.downloadedCount,
                     outcome.summary.conflictCount,
                     outcome.summary.recalculatedCount,
                 ),
                 stringResource(Res.string.settings_sync_last_at, outcome.at.asLabel()),
             )
         }
         is LastSyncOutcome.Failed -> {
             title = stringResource(Res.string.settings_sync_last_failed_title)
             details = listOf(
                 syncErrorMessage(outcome.error),
                 stringResource(Res.string.settings_sync_last_at, outcome.at.asLabel()),
             )
         }
         is LastSyncOutcome.Cancelled -> {
             title = stringResource(Res.string.settings_sync_last_cancelled_title)
             details = listOf(stringResource(Res.string.settings_sync_last_at, outcome.at.asLabel()))
         }
     }
 
     SurferSettingsGroup(title = stringResource(Res.string.settings_sync_section_last)) {
         SurferSettingsRow(
             icon = SurferIcons.Clock,
             title = title,
             supporting = { DetailLines(details) },
             multiline = true,
             modifier = Modifier.testTag(SyncTestTags.LastOutcome),
         )
     }
 }
 
 /**
  * Sync being off is not the same answer as an empty queue: the view model stops reading the outbox
  * then, while the rows stay in Room waiting for the switch to come back. Saying "nothing queued"
  * there would report a fact nobody checked, so the disabled case gets its own row and the header
  * drops the count.
  */
 @Composable
 private fun OutboxSection(state: SyncState) {
     if (!state.syncEnabled) {
         SurferSettingsGroup(
             modifier = Modifier.testTag(SyncTestTags.Outbox),
             title = stringResource(Res.string.settings_sync_section_outbox_unknown),
         ) {
             SurferSettingsRow(
                 icon = SurferIcons.Cloud,
                 title = stringResource(Res.string.settings_sync_outbox_unknown_title),
                 supportingText = stringResource(Res.string.settings_sync_outbox_unknown_supporting),
                 multiline = true,
                 modifier = Modifier.testTag(SyncTestTags.OutboxUnknown),
             )
         }
         return
     }
 
     val rows = state.visibleOutbox
     SurferSettingsGroup(
         modifier = Modifier.testTag(SyncTestTags.Outbox),
         title = stringResource(Res.string.settings_sync_section_outbox, rows.size),
         footnote = when {
             rows.isEmpty() -> null
             state.outboxTruncated -> stringResource(Res.string.settings_sync_outbox_footnote) + " " +
                 stringResource(Res.string.settings_sync_outbox_truncated, rows.size)
             else -> stringResource(Res.string.settings_sync_outbox_footnote)
         },
     ) {
         if (rows.isEmpty()) {
             SurferSettingsRow(
                 icon = SurferIcons.Check,
                 title = stringResource(Res.string.settings_sync_outbox_empty_title),
                 supportingText = stringResource(Res.string.settings_sync_outbox_empty_supporting),
                 modifier = Modifier.testTag(SyncTestTags.OutboxEmpty),
             )
             return@SurferSettingsGroup
         }
         rows.forEach { row -> OutboxRow(row) }
     }
 }
 
 @Composable
 private fun OutboxRow(row: PendingMutation) {
     val scope = row.scopeKey ?: stringResource(Res.string.settings_sync_outbox_scope_root)
     val details = buildList {
         add(row.entityId)
         add(stringResource(Res.string.settings_sync_outbox_scope, scope))
         add(stringResource(Res.string.settings_sync_outbox_attempts, row.attempts))
         row.lastError?.let { add(stringResource(Res.string.settings_sync_outbox_error, it)) }
     }
     SurferSettingsRow(
         icon = SurferIcons.ArrowUp,
         title = stringResource(Res.string.settings_sync_outbox_row_title, row.operation.name, row.entityType),
         supporting = { DetailLines(details, highlightLast = row.lastError != null) },
         multiline = true,
         modifier = Modifier.testTag(SyncTestTags.outboxRow(row.id)),
     )
 }
 
 /**
  * Per-collection pull cursors. This section is the reason the screen exists: `lastPulledAt` is the
  * `updatedAt >` filter of the next pull, so a cursor sitting ahead of a remote write is why that
  * write never arrives — the failure mode behind issue #356 and the cloud-login hydration bug.
  */
 @Composable
 private fun CursorsSection(cursors: List<SyncMeta>) {
     SurferSettingsGroup(
         modifier = Modifier.testTag(SyncTestTags.Cursors),
         title = stringResource(Res.string.settings_sync_section_cursors),
         footnote = stringResource(Res.string.settings_sync_cursors_footnote),
     ) {
         if (cursors.isEmpty()) {
             SurferSettingsRow(
                 icon = SurferIcons.Download,
                 title = stringResource(Res.string.settings_sync_cursors_empty_title),
                 supportingText = stringResource(Res.string.settings_sync_cursors_empty_supporting),
                 modifier = Modifier.testTag(SyncTestTags.CursorsEmpty),
             )
             return@SurferSettingsGroup
         }
         cursors.forEach { meta ->
             SurferSettingsRow(
                 icon = SurferIcons.Download,
                 title = meta.collection,
                 supporting = {
                     DetailLines(
                         listOf(
                             stringResource(Res.string.settings_sync_cursor_pulled, meta.lastPulledAt.asLabel()),
                             stringResource(Res.string.settings_sync_cursor_success, meta.lastSyncSuccessAt.asLabel()),
                             stringResource(Res.string.settings_sync_cursor_attempt, meta.lastSyncAttemptAt.asLabel()),
                         ),
                     )
                 },
                 multiline = true,
                 modifier = Modifier.testTag(SyncTestTags.cursorRow(meta.collection)),
             )
         }
     }
 }
 
 @Composable
 private fun ActionsSection(
     state: SyncState,
     onEvent: (SyncEvent) -> Unit,
 ) {
     SurferSettingsGroup(
         title = stringResource(Res.string.settings_sync_section_actions),
         footnote = if (state.syncEnabled) {
             null
         } else {
             stringResource(Res.string.settings_sync_actions_disabled_footnote)
         },
     ) {
         SurferSettingsRow(
             icon = SurferIcons.Sync,
             title = stringResource(Res.string.settings_sync_action_force),
             supportingText = stringResource(Res.string.settings_sync_action_force_supporting),
             iconBackground = AppTheme.materialColors.primaryContainer,
             iconTint = AppTheme.materialColors.onPrimaryContainer,
             onClick = { onEvent(SyncEvent.OnSyncClick) },
             trailing = { SurferSettingsChevron() },
             multiline = true,
             modifier = Modifier.testTag(SyncTestTags.ForceSyncRow),
         )
         SurferSettingsRow(
             icon = SurferIcons.Restore,
             title = stringResource(Res.string.settings_sync_action_reset_cursors),
             supportingText = stringResource(Res.string.settings_sync_action_reset_cursors_supporting),
             danger = true,
             // No workspace means no scope to clear; leaving the row inert beats clearing nothing
             // and reporting it as done.
             onClick = if (state.canResetCursors) ({ onEvent(SyncEvent.OnResetCursorsClick) }) else null,
             multiline = true,
             modifier = Modifier.testTag(SyncTestTags.ResetCursorsRow),
         )
     }
 }
 
 /** Stacked diagnostic lines; [highlightLast] paints a trailing error line in the error tint. */
 @Composable
 private fun DetailLines(
     lines: List<String>,
     highlightLast: Boolean = false,
 ) {
     Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
         lines.forEachIndexed { index, line ->
             val isError = highlightLast && index == lines.lastIndex
             Text(
                 text = line,
                 style = AppTheme.typography.labelSmall,
                 color = if (isError) AppTheme.materialColors.error else AppTheme.materialColors.onSurfaceVariant,
             )
         }
     }
 }
 
 @Composable
 @Suppress("CyclomaticComplexMethod")
 private fun syncStepLabel(step: SyncStep): String = when (step) {
     SyncStep.WaitingForNetwork -> stringResource(Res.string.settings_sync_step_waiting_network)
     SyncStep.Started -> stringResource(Res.string.settings_sync_step_started)
     SyncStep.UploadingPendingChanges -> stringResource(Res.string.settings_sync_step_uploading)
     is SyncStep.UploadingEntity -> stringResource(
         Res.string.settings_sync_step_uploading_entity,
         step.entityType,
         step.current,
         step.total,
     )
     SyncStep.PullingRemoteChanges -> stringResource(Res.string.settings_sync_step_pulling)
     is SyncStep.PullingCollection ->
         stringResource(Res.string.settings_sync_step_pulling_collection, step.collection)
     SyncStep.RecalculatingProjections -> stringResource(Res.string.settings_sync_step_recalculating)
     is SyncStep.Completed -> stringResource(Res.string.settings_sync_step_completed)
     is SyncStep.Cancelled -> stringResource(Res.string.settings_sync_step_cancelled)
     is SyncStep.Failed -> stringResource(Res.string.settings_sync_step_failed)
 }
 
 @Composable
 private fun syncErrorMessage(error: SyncError): String = when (error) {
     SyncError.Cancelled -> stringResource(Res.string.settings_sync_error_cancelled)
     SyncError.NetworkUnavailable -> stringResource(Res.string.settings_sync_error_network)
     SyncError.AuthRequired -> stringResource(Res.string.settings_sync_error_auth)
     SyncError.PermissionDenied -> stringResource(Res.string.settings_sync_error_permission)
     // Server-authored copy naming the required version — showing it beats a generic line.
     is SyncError.UnsupportedAppVersion -> error.message
     is SyncError.StorageError -> stringResource(Res.string.settings_sync_error_storage)
     is SyncError.Unknown -> error.cause.message ?: stringResource(Res.string.settings_sync_error_unknown)
 }
 
 /** Local wall-clock stamp; [Res.string.settings_sync_value_never] stands in for an absent one. */
 @Composable
 private fun Instant?.asLabel(): String {
     val instant = this ?: return stringResource(Res.string.settings_sync_value_never)
     val local = instant.toLocalDateTime(TimeZone.currentSystemDefault())
     val time = listOf(local.hour, local.minute, local.second)
         .joinToString(":") { it.toString().padStart(2, '0') }
     return "${local.date} $time"
 }