feat(#617): skeleton loaders instead of bare spinner on list screens (#625)

Adds SkeletonListState to StateViews.kt — an animated shimmer
placeholder list whose geometry matches a typical list row (avatar circle
+ title line + subtitle line). The shimmer colors derive from
MaterialTheme.colorScheme.surfaceVariant (theme-aware, works on
light/dark/AMOLED/Material You). Sweeps horizontally via an
infiniteTransition tween — no new dependencies, pure Compose foundation
animations.

Wired SkeletonListState into 22 list-loading call sites across 22 screens:
skills, cron, plugins, mcp, toolsets, webhooks, system, sessions (x2),
config (x2 — list + YAMLEditor), model, profiles, keys, channels,
achievements, gateways, kanban, logs, analytics, providers,
processes, hub skills tab, installed skills tab (list branch).

Preserved LoadingState() (centered spinner) where the issue explicitly
calls for it:
- PairingScreen (one-shot auth flow)
- SkillEditorDialog / ModelPickerDialog / CronJobEditorDialog interiors
- SkillPreviewDialog and SkillEditorDialog inline-loading branches in
  SkillsScreen.kt (lines 790, 914)
- InstalledSkillsTab dialog-interior branch (line 393)

SkeletonListState testTag: "skeleton_list_state".

Verified: ktlintCheck, assembleDebug, testDebugUnitTest all green locally.

Co-authored-by: Hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andHy4ri authored and GitHub committed 2026-07-18 15:12:21 +03:00
1 parent 0a0b612d3a
commit 0018e5d172
23 files changed
+186 -53

No files matched your search

@@ -1,14 +1,26 @@
package com.m57.hermescontrol.ui.common
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
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.PaddingValues
import androidx.compose.foundation.layout.Row
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.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Inbox
import androidx.compose.material.icons.filled.Refresh
@@ -19,12 +31,20 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalSpacing
@@ -168,6 +188,125 @@ fun EmptyState(
}
}
// ── Skeleton (shimmer) — see issue #617 ────────────────────────────────
//
// Skeleton loaders for list screens. Replaces bare `CircularProgressIndicator`
// in list screens to (1) reduce perceived latency (~2× per NN/g research)
// and (2) eliminate layout shift when real content lands. Pair with
// `LoadingState()` for centered/one-shot contexts (Pairing flow, dialog
// interiors) where a spinner is still appropriate.
/**
* An animated shimmer [Brush] derived from the current theme's
* `MaterialTheme.colorScheme.surfaceVariant`. Sweeps horizontally across the
* canvas; offset driven by an infinite `1.2s` tween so the shimmer never
* freezes. Theme-derived — works on light, dark, AMOLED, Material You.
*/
@Composable
private fun shimmerBrush(): Brush {
val base = MaterialTheme.colorScheme.surfaceVariant
val highlight = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
val transition = rememberInfiniteTransition(label = "shimmer")
val widthPx = with(LocalDensity.current) { LocalConfiguration.current.screenWidthDp.dp.toPx() }
val offset by transition.animateFloat(
initialValue = -widthPx,
targetValue = widthPx,
animationSpec =
infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmer_offset",
)
return Brush.linearGradient(
colors = listOf(base, highlight, base),
start = Offset(offset, 0f),
end = Offset(offset + widthPx, 0f),
)
}
@Composable
private fun ShimmerBox(
widthFraction: Float,
height: Dp,
shape: Shape,
modifier: Modifier = Modifier,
) {
Box(
modifier =
modifier
.height(height)
.fillMaxWidth(widthFraction)
.clip(shape)
.background(shimmerBrush()),
)
}
@Composable
private fun SkeletonRow(height: Dp) {
val spacing = LocalSpacing.current
val shape = RoundedCornerShape(12.dp)
Row(
modifier =
Modifier
.fillMaxWidth()
.height(height)
.padding(vertical = spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
// Avatar circle — 40dp to match default LoadingState spinner size.
ShimmerBox(
widthFraction = 0f,
height = 40.dp,
shape = CircleShape,
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.width(spacing.md))
// Title + subtitle lines
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(spacing.sm),
) {
ShimmerBox(widthFraction = 0.6f, height = 14.dp, shape = shape)
ShimmerBox(widthFraction = 0.4f, height = 12.dp, shape = shape)
}
}
}
/**
* Skeleton placeholder list for loading states on list/management screens.
*
* Renders [itemCount] shimmering rows whose geometry matches the typical list
* row (avatar circle + title line + subtitle line). The shimmer colors derive
* from theme tokens — works on light, dark, AMOLED, and Material You. No
* layout shift on transition to real content (acceptance criterion, issue #617).
*
* @param itemCount number of placeholder rows to render (default 6 — long
* enough to fill a phone viewport under typical content density).
* @param rowHeight height of each placeholder row (default 72dp — same as a
* one-line `NavigationDrawerItem`). Override per-screen to match real row
* geometry when known.
* @param modifier outer modifier; pass `Modifier.padding(paddingValues)` from
* the scaffold so the skeleton offsets for the top bar like real content does.
*/
@Composable
fun SkeletonListState(
itemCount: Int = 6,
rowHeight: Dp = 72.dp,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.fillMaxSize()
.padding(horizontal = 12.dp)
.testTag("skeleton_list_state"),
verticalArrangement = listItemSpacing,
) {
repeat(itemCount) { SkeletonRow(height = rowHeight) }
}
}
// ── Padding conventions ──────────────────────────────────────────────
val listContentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp)