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:
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)
|
||||
|
||||
Reference in new issue
Block a user