feat(#363): add descriptive text to loading spinners (#406)

* feat(#363): add descriptive text to loading spinners

- Updates LoadingState composable to accept optional subtitle
- Shows 'Fetching model providers...' on ModelScreen
- Shows 'Loading channels...' on ChannelsScreen
- Removes dead/unreachable CircularProgressIndicator from ModelScreen

* fix(#363): pass loading_state_subtitle_models string to LoadingState

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-06-27 01:26:27 +03:00
1 parent 96e754dd3b
commit c0ebc8ec64
4 files changed
+191 -190

No files matched your search

@@ -73,7 +73,10 @@ fun ChannelsScreen(
) { paddingValues -> ) { paddingValues ->
when { when {
state.isLoading && state.platforms.isEmpty() -> { state.isLoading && state.platforms.isEmpty() -> {
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState(
subtitle = stringResource(R.string.loading_state_subtitle_channels),
modifier = Modifier.padding(paddingValues),
)
} }
state.errorMessage != null -> { state.errorMessage != null -> {
@@ -35,7 +35,10 @@ import com.m57.hermescontrol.theme.LocalSpacing
// ── Loading ─────────────────────────────────────────────────────────── // ── Loading ───────────────────────────────────────────────────────────
@Composable @Composable
fun LoadingState(modifier: Modifier = Modifier) { fun LoadingState(
modifier: Modifier = Modifier,
subtitle: String? = null,
) {
Box( Box(
modifier = modifier =
modifier modifier
@@ -43,14 +46,24 @@ fun LoadingState(modifier: Modifier = Modifier) {
.testTag("loading_state"), .testTag("loading_state"),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
CircularProgressIndicator( Column(horizontalAlignment = Alignment.CenterHorizontally) {
color = MaterialTheme.colorScheme.primary, CircularProgressIndicator(
strokeWidth = 3.dp, color = MaterialTheme.colorScheme.primary,
modifier = strokeWidth = 3.dp,
Modifier modifier =
.size(40.dp) Modifier
.testTag("loading_spinner"), .size(40.dp)
) .testTag("loading_spinner"),
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} }
} }
@@ -3,7 +3,6 @@ package com.m57.hermescontrol.ui.model
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -16,13 +15,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -83,7 +79,10 @@ fun ModelScreen(
) { paddingValues -> ) { paddingValues ->
when { when {
state.isLoading && state.providers.isEmpty() -> { state.isLoading && state.providers.isEmpty() -> {
LoadingState(modifier = Modifier.padding(paddingValues)) LoadingState(
subtitle = stringResource(R.string.loading_state_subtitle_models),
modifier = Modifier.padding(paddingValues),
)
} }
state.errorMessage != null -> { state.errorMessage != null -> {
@@ -105,190 +104,171 @@ fun ModelScreen(
} }
else -> { else -> {
Box(Modifier.fillMaxSize()) { LazyColumn(
if (state.isLoading && state.providers.isEmpty()) { modifier = Modifier.fillMaxSize(),
CircularProgressIndicator() contentPadding = PaddingValues(16.dp),
} else if (state.errorMessage != null && state.providers.isEmpty()) { verticalArrangement = Arrangement.spacedBy(12.dp),
Column( ) {
modifier = Modifier.padding(16.dp), item {
horizontalAlignment = Alignment.CenterHorizontally, SearchBar(
) { query = query,
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error) onQueryChange = { query = it },
Spacer(modifier = Modifier.height(16.dp)) placeholder = "Search models...",
IconButton(onClick = { viewModel.loadModelOptions() }) { )
Icon( }
imageVector = Icons.Filled.Refresh, items(filteredProviders, key = { it.slug }) { provider ->
contentDescription = stringResource(R.string.action_retry), val isExpanded = expandedProviderSlug == provider.slug
) val isCurrent = provider.is_current == true
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
SearchBar(
query = query,
onQueryChange = { query = it },
placeholder = "Search models...",
)
}
items(filteredProviders, key = { it.slug }) { provider ->
val isExpanded = expandedProviderSlug == provider.slug
val isCurrent = provider.is_current == true
Card( Card(
modifier = Modifier.fillMaxWidth(),
onClick = {
expandedProviderSlug = if (isExpanded) null else provider.slug
},
colors =
CardDefaults.cardColors(
containerColor =
if (isCurrent) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
onClick = { horizontalArrangement = Arrangement.SpaceBetween,
expandedProviderSlug = if (isExpanded) null else provider.slug verticalAlignment = Alignment.CenterVertically,
},
colors =
CardDefaults.cardColors(
containerColor =
if (isCurrent) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
) { ) {
Column( Column {
modifier = Text(
Modifier text = provider.name,
.fillMaxWidth() style = MaterialTheme.typography.titleMedium,
.padding(16.dp), fontWeight = FontWeight.Bold,
) { )
Row( Text(
modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.model_label_slug, provider.slug),
horizontalArrangement = Arrangement.SpaceBetween, style = MaterialTheme.typography.bodySmall,
verticalAlignment = Alignment.CenterVertically, color = MaterialTheme.colorScheme.onSurfaceVariant,
) { )
Column { }
Text(
text = provider.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(R.string.model_label_slug, provider.slug),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (isCurrent) { if (isCurrent) {
Text( Text(
text = stringResource(R.string.model_status_current), text = stringResource(R.string.model_status_current),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
} }
provider.warning?.let { provider.warning?.let {
if (it.isNotBlank()) { if (it.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
) )
} }
} }
AnimatedVisibility(visible = isExpanded) { AnimatedVisibility(visible = isExpanded) {
Column(modifier = Modifier.padding(top = 16.dp)) { Column(modifier = Modifier.padding(top = 16.dp)) {
Text( Text(
text = stringResource(R.string.model_label_available), text = stringResource(R.string.model_label_available),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
) )
val models = provider.models.orEmpty() val models = provider.models.orEmpty()
if (models.isEmpty()) { if (models.isEmpty()) {
Text( Text(
text = stringResource(R.string.model_no_models), text = stringResource(R.string.model_no_models),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} else { } else {
models.forEach { model -> models.forEach { model ->
val isActive = val isActive =
state.activeProfile?.provider == provider.slug && state.activeProfile?.provider == provider.slug &&
state.activeProfile?.model == model state.activeProfile?.model == model
Card( Card(
onClick = { onClick = {
viewModel.selectModel(provider.slug, model) viewModel.selectModel(provider.slug, model)
}, },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
colors = colors =
CardDefaults.cardColors( CardDefaults.cardColors(
containerColor = containerColor =
if (isActive) {
MaterialTheme.colorScheme
.primaryContainer
.copy(
alpha = 0.3f,
)
} else {
MaterialTheme.colorScheme.surface
},
),
border =
BorderStroke(
width = 1.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(
alpha = 0.2f,
)
},
),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = model,
style = MaterialTheme.typography.bodyMedium,
fontWeight =
if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (isActive) { if (isActive) {
Icon( MaterialTheme.colorScheme
imageVector = Icons.Filled.Check, .primaryContainer
contentDescription = .copy(
stringResource( alpha = 0.3f,
R.string.content_desc_active_model, )
), } else {
tint = MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.surface
},
),
border =
BorderStroke(
width = 1.dp,
color =
if (isActive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(
alpha = 0.2f,
) )
} },
} ),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement =
Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = model,
style = MaterialTheme.typography.bodyMedium,
fontWeight =
if (isActive) {
FontWeight.Bold
} else {
FontWeight.Normal
},
color =
if (isActive) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurface
},
)
if (isActive) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription =
stringResource(
R.string.content_desc_active_model,
),
tint = MaterialTheme.colorScheme.primary,
)
} }
} }
} }
+5
View File
@@ -397,4 +397,9 @@
<string name="landing_subtitle">Control panel for Hermes Agent</string> <string name="landing_subtitle">Control panel for Hermes Agent</string>
<string name="landing_action_auth_login">Auth Login</string> <string name="landing_action_auth_login">Auth Login</string>
<string name="landing_action_pairing_login">Pairing Login</string> <string name="landing_action_pairing_login">Pairing Login</string>
<!-- Loading State -->
<string name="loading_state_subtitle_default">Loading…</string>
<string name="loading_state_subtitle_models">Fetching model providers…</string>
<string name="loading_state_subtitle_channels">Loading channels…</string>
</resources> </resources>