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