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:
M57andM57 authored and GitHub committed 2026-07-22 15:03:30 +03:00
1 parent f7afbfae85
commit 1cca7764cc
7 files changed
+222 -172

No files matched your search

@@ -6,9 +6,9 @@ import androidx.navigation3.runtime.NavKey
/**
* Central navigation controller with deduplication guard.
*
* Primary screens (bottom nav tabs) clear the back stack and become the new
* root — this matches Material's bottom-nav pattern where each tab has its own
* back stack history (simplified: we just clear).
* Top-level primary screens (drawer items) clear the back stack and become the new
* root — this matches navigation drawer patterns where switching top-level sections
* resets the stack.
*
* B7 (Jun 18 2026): Never call `backStack.add()` directly from UI callbacks.
* Always route through [navigateTo] to prevent stacking duplicate screen
@@ -18,9 +18,7 @@ object NavigationController {
var backStack: NavBackStack<NavKey>? = null
var pendingSessionId: String? = null
// Bottom-nav primary screens — dynamic, updated by Navigation.kt via
// updatePrimaryScreens() when the user customises the bottom nav bar.
// Default matches the default 5 bottom-nav items.
// Top-level primary screens (Chat, Skills, Cron, System, Settings)
private val primaryScreens: MutableSet<NavKey> =
mutableSetOf(
ChatScreen,
@@ -30,16 +28,9 @@ object NavigationController {
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
/** 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) {
val stack = backStack ?: 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.HermesWsClient
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -47,54 +49,61 @@ class BillingViewModel : ViewModel() {
_uiState.update { it.copy(isLoading = true, errorMessage = null, featureUnavailable = false) }
loadJob =
viewModelScope.launch {
var sub: SubscriptionStateResponse? = null
var bars: UsageBarsResponse? = null
var unavailable = false
var error: String? = null
try {
sub = BillingRepository.getSubscriptionState()
// The live gateway returns an `ok:false` *result* (with
// `error`/`message`) rather than an RPC-level error when the
// backend feature is missing or the user isn't portal-authed.
// Catch that here so the screen degrades gracefully.
if (sub?.ok == false) {
unavailable = true
error = sub.error ?: "Billing unavailable"
} else if (sub == null) {
// Backend returned no result payload (null result / unmapped
// response). Treat as a non-fatal load miss, not a crash.
if (error == null) error = "No billing data returned"
coroutineScope {
val subDeferred =
async {
var sub: SubscriptionStateResponse? = null
var unavailable = false
var error: String? = null
try {
sub = BillingRepository.getSubscriptionState()
if (sub?.ok == false) {
unavailable = true
error = sub.error ?: "Billing unavailable"
} else if (sub == null) {
if (error == null) error = "No billing data returned"
}
} 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"
}
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.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -51,53 +52,55 @@ class ConfigViewModel :
}
fun loadAll() {
safeLaunchLoad(
apiCall = {
safeApiCall { ApiClient.hermesApi.getConfig() }
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { configData ->
viewModelScope.launch {
val schemaResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfigSchema() }
}
val defaultsResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getConfigDefaults() }
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
coroutineScope {
val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
val defaultsDeferred =
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data
val rawResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getRawConfig() }
}
val path = (rawResult as? NetworkResult.Success)?.data?.path
val configResult = configDeferred.await()
val schemaResult = schemaDeferred.await()
val defaultsResult = defaultsDeferred.await()
val rawResult = rawDeferred.await()
_uiState.update {
it.copy(
isLoading = false,
config = configData,
schema = schema,
defaults = defaults,
path = path,
activeCategory =
if (schema?.category_order?.isNotEmpty() == true) {
schema.category_order.first()
} else {
""
},
)
if (configResult is NetworkResult.Success) {
val configData = configResult.data
val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult as? NetworkResult.Success)?.data
val path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update {
it.copy(
isLoading = false,
config = configData,
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")
}
}
}
},
onError = { errorMsg ->
} catch (e: Exception) {
_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(
@@ -17,6 +17,8 @@ import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -49,31 +51,39 @@ class ProfilesViewModel :
fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
val profilesResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getProfiles() }
}
val activeResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getActiveProfile() }
}
try {
coroutineScope {
val profilesDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getProfiles() } }
val activeDeferred =
async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } }
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
_uiState.update {
it.copy(
isLoading = false,
profiles = profilesResult.data.profiles.orEmpty(),
activeProfileName = activeResult.data.active,
)
val profilesResult = profilesDeferred.await()
val activeResult = activeDeferred.await()
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
_uiState.update {
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 {
val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success"
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load profiles/active: Profiles: $profilesError, Active: $activeError",
)
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
}
}
}
@@ -289,30 +299,40 @@ class ProfilesViewModel :
fun loadBuilderData() {
_uiState.update { it.copy(isLoadingBuilderData = true, errorMessage = null) }
viewModelScope.launch {
val modelsResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getModelOptions() }
}
val skillsResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getSkills() }
}
try {
coroutineScope {
val modelsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getModelOptions() } }
val skillsDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSkills() } }
if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) {
_uiState.update {
it.copy(
isLoadingBuilderData = false,
modelProviders = modelsResult.data.providers,
availableSkills = skillsResult.data,
)
val modelsResult = modelsDeferred.await()
val skillsResult = skillsDeferred.await()
if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) {
_uiState.update {
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 {
val modelsError = (modelsResult as? NetworkResult.Failure)?.error?.message ?: "Success"
val skillsError = (skillsResult as? NetworkResult.Failure)?.error?.message ?: "Success"
} catch (e: Exception) {
_uiState.update {
it.copy(
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 ────────────────────────────────────────────────
fun startGateway() {
@@ -182,7 +229,7 @@ class SystemViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Gateway ${name}ed successfully") }
loadAll()
loadStatus()
}
is NetworkResult.Failure -> {
@@ -249,7 +296,7 @@ class SystemViewModel :
toastMessage = "Curator ${if (!currentlyPaused) "paused" else "resumed"}",
)
}
loadAll()
loadCurator()
}
is NetworkResult.Failure -> {
@@ -277,7 +324,7 @@ class SystemViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Memory ($target) reset successfully") }
loadAll()
loadMemory()
}
is NetworkResult.Failure -> {
@@ -329,7 +376,7 @@ class SystemViewModel :
toastMessage = "Credential added",
)
}
loadAll()
loadCredentials()
}
is NetworkResult.Failure -> {
@@ -353,7 +400,7 @@ class SystemViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Credential removed") }
loadAll()
loadCredentials()
}
is NetworkResult.Failure -> {
@@ -561,7 +608,7 @@ class SystemViewModel :
toastMessage = "Hook created",
)
}
loadAll()
loadHooks()
}
is NetworkResult.Failure -> {
@@ -588,7 +635,7 @@ class SystemViewModel :
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Hook deleted") }
loadAll()
loadHooks()
}
is NetworkResult.Failure -> {