perf: optimize data loading across Config, System, Profiles, and Billing tabs (#678)

* perf(model): optimize model tab load speed with two-phase fetch and loadQuick

* perf: optimize data loading across Config, System, Profiles, and Billing tabs

* chore: remove leftover bottom nav references and unused updatePrimaryScreens

* style: fix ktlint violations in Config/Profiles/Billing ViewModels

- property/argument-list wrapping for async(...) calls
- multiline-expression wrapping for async blocks
- remove unused import in ConfigViewModel
- break long error-message string literals to stay under 120 cols

---------

Co-authored-by: M57 <Hy4ri@users.noreply.github.com>
This commit is contained in:
M57
2026-07-22 15:03:30 +03:00
committed by GitHub
co-authored by M57
parent f7afbfae85
commit 1cca7764cc
7 changed files with 222 additions and 172 deletions
-2
View File
@@ -253,8 +253,6 @@ gh pr create --title "fix(#N): description" --body "Closes #N"
- Don't run `./gradlew` tasks without an Android SDK — CI handles compilation, lint, and tests. - Don't run `./gradlew` tasks without an Android SDK — CI handles compilation, lint, and tests.
- Don't remove `@OptIn(ExperimentalMaterial3Api::class)` from screens that use - Don't remove `@OptIn(ExperimentalMaterial3Api::class)` from screens that use
`SegmentedButton`, `PullToRefreshBox`, or other experimental M3 APIs. `SegmentedButton`, `PullToRefreshBox`, or other experimental M3 APIs.
- Don't use `remember { AuthManager.getBottomNavItems() }` without a key —
SharedPreferences is not Compose state; the value goes stale. Read it directly.
- Don't access `MaterialTheme.colorScheme.*` inside non-`@Composable` lambdas - Don't access `MaterialTheme.colorScheme.*` inside non-`@Composable` lambdas
(`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local (`remember {}`, `buildAnnotatedString`, `LaunchedEffect`). Extract to a local
`val` at the composable scope first. `val` at the composable scope first.
@@ -6,9 +6,9 @@ import androidx.navigation3.runtime.NavKey
/** /**
* Central navigation controller with deduplication guard. * Central navigation controller with deduplication guard.
* *
* Primary screens (bottom nav tabs) clear the back stack and become the new * Top-level primary screens (drawer items) clear the back stack and become the new
* root — this matches Material's bottom-nav pattern where each tab has its own * root — this matches navigation drawer patterns where switching top-level sections
* back stack history (simplified: we just clear). * resets the stack.
* *
* B7 (Jun 18 2026): Never call `backStack.add()` directly from UI callbacks. * B7 (Jun 18 2026): Never call `backStack.add()` directly from UI callbacks.
* Always route through [navigateTo] to prevent stacking duplicate screen * Always route through [navigateTo] to prevent stacking duplicate screen
@@ -18,9 +18,7 @@ object NavigationController {
var backStack: NavBackStack<NavKey>? = null var backStack: NavBackStack<NavKey>? = null
var pendingSessionId: String? = null var pendingSessionId: String? = null
// Bottom-nav primary screens — dynamic, updated by Navigation.kt via // Top-level primary screens (Chat, Skills, Cron, System, Settings)
// updatePrimaryScreens() when the user customises the bottom nav bar.
// Default matches the default 5 bottom-nav items.
private val primaryScreens: MutableSet<NavKey> = private val primaryScreens: MutableSet<NavKey> =
mutableSetOf( mutableSetOf(
ChatScreen, ChatScreen,
@@ -30,16 +28,9 @@ object NavigationController {
SettingsScreen, SettingsScreen,
) )
/** Returns whether the given key is a primary (bottom-nav) screen. */ /** Returns whether the given key is a primary top-level screen. */
fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens fun isPrimaryScreen(key: NavKey): Boolean = key in primaryScreens
/** Replace the primary screen set. Called by Navigation.kt when the
* bottom-nav item config changes. */
fun updatePrimaryScreens(keys: Set<NavKey>) {
primaryScreens.clear()
primaryScreens.addAll(keys)
}
fun navigateTo(key: NavKey) { fun navigateTo(key: NavKey) {
val stack = backStack ?: return val stack = backStack ?: return
if (stack.lastOrNull() == key) return if (stack.lastOrNull() == key) return
@@ -8,6 +8,8 @@ import com.m57.hermescontrol.data.model.UsageBarsResponse
import com.m57.hermescontrol.data.ws.BillingRepository import com.m57.hermescontrol.data.ws.BillingRepository
import com.m57.hermescontrol.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -47,54 +49,61 @@ class BillingViewModel : ViewModel() {
_uiState.update { it.copy(isLoading = true, errorMessage = null, featureUnavailable = false) } _uiState.update { it.copy(isLoading = true, errorMessage = null, featureUnavailable = false) }
loadJob = loadJob =
viewModelScope.launch { viewModelScope.launch {
var sub: SubscriptionStateResponse? = null coroutineScope {
var bars: UsageBarsResponse? = null val subDeferred =
var unavailable = false async {
var error: String? = null var sub: SubscriptionStateResponse? = null
try { var unavailable = false
sub = BillingRepository.getSubscriptionState() var error: String? = null
// The live gateway returns an `ok:false` *result* (with try {
// `error`/`message`) rather than an RPC-level error when the sub = BillingRepository.getSubscriptionState()
// backend feature is missing or the user isn't portal-authed. if (sub?.ok == false) {
// Catch that here so the screen degrades gracefully. unavailable = true
if (sub?.ok == false) { error = sub.error ?: "Billing unavailable"
unavailable = true } else if (sub == null) {
error = sub.error ?: "Billing unavailable" if (error == null) error = "No billing data returned"
} else if (sub == null) { }
// Backend returned no result payload (null result / unmapped } catch (e: HermesWsClient.HermesRpcException) {
// response). Treat as a non-fatal load miss, not a crash. unavailable = true
if (error == null) error = "No billing data returned" error = e.message
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
error = e.message ?: "Failed to load subscription"
}
Triple(sub, unavailable, error)
}
val barsDeferred =
async {
var bars: UsageBarsResponse? = null
var error: String? = null
try {
bars = BillingRepository.getUsageBars()
if (bars?.ok == false) {
error = "Usage data unavailable"
}
} catch (e: HermesWsClient.HermesRpcException) {
// usage unavailable is non-fatal
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
error = e.message ?: "Failed to load usage"
}
Pair(bars, error)
}
val (sub, unavailable, subError) = subDeferred.await()
val (bars, barsError) = barsDeferred.await()
val error = subError ?: barsError
_uiState.update {
it.copy(
isLoading = false,
subscription = sub,
usage = bars,
featureUnavailable = unavailable,
errorMessage = if (sub == null) error else null,
)
} }
} catch (e: HermesWsClient.HermesRpcException) {
unavailable = true
error = e.message
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
error = e.message ?: "Failed to load subscription"
}
try {
bars = BillingRepository.getUsageBars()
if (bars?.ok == false) {
// usage unavailable is non-fatal — the plan card still renders
if (error == null) error = "Usage data unavailable"
}
} catch (e: HermesWsClient.HermesRpcException) {
// usage unavailable is non-fatal — do NOT mark the whole
// feature unavailable; the plan card still renders.
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
if (error == null) error = e.message ?: "Failed to load usage"
}
_uiState.update {
it.copy(
isLoading = false,
subscription = sub,
usage = bars,
featureUnavailable = unavailable,
// A failed primary subscription load is fatal — surface it
// even if usage bars happened to load.
errorMessage = if (sub == null) error else null,
)
} }
} }
} }
@@ -9,8 +9,9 @@ import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -51,53 +52,55 @@ class ConfigViewModel :
} }
fun loadAll() { fun loadAll() {
safeLaunchLoad( viewModelScope.launch {
apiCall = { _uiState.update { it.copy(isLoading = true, errorMessage = null) }
safeApiCall { ApiClient.hermesApi.getConfig() } try {
}, coroutineScope {
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
onSuccess = { configData -> val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
viewModelScope.launch { val defaultsDeferred =
val schemaResult = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
withContext(Dispatchers.IO) { val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
safeApiCall { ApiClient.hermesApi.getConfigSchema() }
}
val defaultsResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfigDefaults() }
}
val schema = (schemaResult as? NetworkResult.Success)?.data val configResult = configDeferred.await()
val defaults = (defaultsResult as? NetworkResult.Success)?.data val schemaResult = schemaDeferred.await()
val rawResult = val defaultsResult = defaultsDeferred.await()
withContext(Dispatchers.IO) { val rawResult = rawDeferred.await()
safeApiCall { ApiClient.hermesApi.getRawConfig() }
}
val path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update { if (configResult is NetworkResult.Success) {
it.copy( val configData = configResult.data
isLoading = false, val schema = (schemaResult as? NetworkResult.Success)?.data
config = configData, val defaults = (defaultsResult as? NetworkResult.Success)?.data
schema = schema, val path = (rawResult as? NetworkResult.Success)?.data?.path
defaults = defaults,
path = path, _uiState.update {
activeCategory = it.copy(
if (schema?.category_order?.isNotEmpty() == true) { isLoading = false,
schema.category_order.first() config = configData,
} else { schema = schema,
"" defaults = defaults,
}, path = path,
) activeCategory =
if (schema?.category_order?.isNotEmpty() == true) {
schema.category_order.first()
} else {
""
},
)
}
} else {
val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg")
}
} }
} }
}, } catch (e: Exception) {
onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load config: $errorMsg") it.copy(isLoading = false, errorMessage = "Failed to load config: ${e.message}")
} }
}, }
) }
} }
fun updateField( fun updateField(
@@ -17,6 +17,8 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -49,31 +51,39 @@ class ProfilesViewModel :
fun loadProfiles() { fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
val profilesResult = try {
withContext(Dispatchers.IO) { coroutineScope {
safeApiCall { ApiClient.hermesApi.getProfiles() } val profilesDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
} val activeDeferred =
val activeResult = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getActiveProfile() }
}
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) { val profilesResult = profilesDeferred.await()
_uiState.update { val activeResult = activeDeferred.await()
it.copy(
isLoading = false, if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
profiles = profilesResult.data.profiles.orEmpty(), _uiState.update {
activeProfileName = activeResult.data.active, it.copy(
) isLoading = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeResult.data.active,
)
}
} else {
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
_uiState.update {
it.copy(
isLoading = false,
errorMessage =
"Failed to load profiles/active: " +
"Profiles: $profilesError, Active: $activeError",
)
}
}
} }
} else { } catch (e: Exception) {
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
_uiState.update { _uiState.update {
it.copy( it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
isLoading = false,
errorMessage = "Failed to load profiles/active: Profiles: $profilesError, Active: $activeError",
)
} }
} }
} }
@@ -289,30 +299,40 @@ class ProfilesViewModel :
fun loadBuilderData() { fun loadBuilderData() {
_uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) } _uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
val modelsResult = try {
withContext(Dispatchers.IO) { coroutineScope {
safeApiCall { ApiClient.hermesApi.getModelOptions() } val modelsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getModelOptions() } }
} val skillsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSkills() } }
val skillsResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSkills() }
}
if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) { val modelsResult = modelsDeferred.await()
_uiState.update { val skillsResult = skillsDeferred.await()
it.copy(
isLoadingBuilderData = false, if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) {
modelProviders = modelsResult.data.providers, _uiState.update {
availableSkills = skillsResult.data, it.copy(
) isLoadingBuilderData = false,
modelProviders = modelsResult.data.providers,
availableSkills = skillsResult.data,
)
}
} else {
val modelsError = (modelsResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val skillsError = (skillsResult as? NetworkResult.Failure)?.error?.message ?: "Success"
_uiState.update {
it.copy(
isLoadingBuilderData = false,
errorMessage =
"Failed to load builder data: " +
"Models: $modelsError, Skills: $skillsError",
)
}
}
} }
} else { } catch (e: Exception) {
val modelsError = (modelsResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val skillsError = (skillsResult as? NetworkResult.Failure)?.error?.message ?: "Success"
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoadingBuilderData = false, isLoadingBuilderData = false,
errorMessage = "Failed to load builder data: Models: $modelsError, Skills: $skillsError", errorMessage = "Failed to load builder data: ${e.message}",
) )
} }
} }
@@ -159,6 +159,53 @@ class SystemViewModel :
} }
} }
// ── Targeted Data Reloads ──────────────────────────────────────────
fun loadCredentials() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCredentialPool() } }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(credentials = result.data.providers ?: emptyList()) }
}
}
}
fun loadHooks() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getHooks() } }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(hooks = result.data) }
}
}
}
fun loadCurator() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCurator() } }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(curator = result.data) }
}
}
}
fun loadMemory() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMemory() } }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(memory = result.data) }
}
}
}
fun loadStatus() {
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() } }
if (result is NetworkResult.Success) {
_uiState.update { it.copy(status = result.data) }
}
}
}
// ── Gateway actions ──────────────────────────────────────────────── // ── Gateway actions ────────────────────────────────────────────────
fun startGateway() { fun startGateway() {
@@ -182,7 +229,7 @@ class SystemViewModel :
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Gateway ${name}ed successfully") } _uiState.update { it.copy(toastMessage = "Gateway ${name}ed successfully") }
loadAll() loadStatus()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -249,7 +296,7 @@ class SystemViewModel :
toastMessage = "Curator ${if (!currentlyPaused) "paused" else "resumed"}", toastMessage = "Curator ${if (!currentlyPaused) "paused" else "resumed"}",
) )
} }
loadAll() loadCurator()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -277,7 +324,7 @@ class SystemViewModel :
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") } _uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") }
loadAll() loadMemory()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -329,7 +376,7 @@ class SystemViewModel :
toastMessage = "Credential added", toastMessage = "Credential added",
) )
} }
loadAll() loadCredentials()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -353,7 +400,7 @@ class SystemViewModel :
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Credential removed") } _uiState.update { it.copy(toastMessage = "Credential removed") }
loadAll() loadCredentials()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -561,7 +608,7 @@ class SystemViewModel :
toastMessage = "Hook created", toastMessage = "Hook created",
) )
} }
loadAll() loadHooks()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -588,7 +635,7 @@ class SystemViewModel :
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Hook deleted") } _uiState.update { it.copy(toastMessage = "Hook deleted") }
loadAll() loadHooks()
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -191,7 +191,7 @@ class NavigationControllerTest {
assertNull(NavigationController.backStack) assertNull(NavigationController.backStack)
} }
// ── primaryScreens and updatePrimaryScreens ─────────────────────────── // ── primaryScreens ────────────────────────────────────────────────────
@Test @Test
fun `isPrimaryScreen returns true for default screens`() { fun `isPrimaryScreen returns true for default screens`() {
@@ -208,22 +208,4 @@ class NavigationControllerTest {
assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen)) assertFalse("LogsScreen should NOT be primary", NavigationController.isPrimaryScreen(LogsScreen))
assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen)) assertFalse("ConfigScreen should NOT be primary", NavigationController.isPrimaryScreen(ConfigScreen))
} }
@Test
fun `updatePrimaryScreens replaces the full screen set`() {
NavigationController.updatePrimaryScreens(setOf(ProfilesScreen, SystemScreen))
assertTrue("ProfilesScreen should now be primary", NavigationController.isPrimaryScreen(ProfilesScreen))
assertTrue("SystemScreen should still be primary", NavigationController.isPrimaryScreen(SystemScreen))
// Old defaults should no longer be primary
assertFalse("ChatScreen should no longer be primary", NavigationController.isPrimaryScreen(ChatScreen))
assertFalse("SkillsScreen should no longer be primary", NavigationController.isPrimaryScreen(SkillsScreen))
assertFalse("SettingsScreen should no longer be primary", NavigationController.isPrimaryScreen(SettingsScreen))
// Reset to defaults to avoid test pollution
NavigationController.updatePrimaryScreens(
setOf(ChatScreen, SkillsScreen, CronJobsScreen, SystemScreen, SettingsScreen),
)
}
} }