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
+148 -98

No files matched your search

-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,22 +49,18 @@ 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 {
coroutineScope {
val subDeferred =
async {
var sub: SubscriptionStateResponse? = null var sub: SubscriptionStateResponse? = null
var bars: UsageBarsResponse? = null
var unavailable = false var unavailable = false
var error: String? = null var error: String? = null
try { try {
sub = BillingRepository.getSubscriptionState() 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) { if (sub?.ok == false) {
unavailable = true unavailable = true
error = sub.error ?: "Billing unavailable" error = sub.error ?: "Billing unavailable"
} else if (sub == null) { } 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" if (error == null) error = "No billing data returned"
} }
} catch (e: HermesWsClient.HermesRpcException) { } catch (e: HermesWsClient.HermesRpcException) {
@@ -72,32 +70,43 @@ class BillingViewModel : ViewModel() {
if (e is kotlinx.coroutines.CancellationException) throw e if (e is kotlinx.coroutines.CancellationException) throw e
error = e.message ?: "Failed to load subscription" error = e.message ?: "Failed to load subscription"
} }
Triple(sub, unavailable, error)
}
val barsDeferred =
async {
var bars: UsageBarsResponse? = null
var error: String? = null
try { try {
bars = BillingRepository.getUsageBars() bars = BillingRepository.getUsageBars()
if (bars?.ok == false) { if (bars?.ok == false) {
// usage unavailable is non-fatal — the plan card still renders error = "Usage data unavailable"
if (error == null) error = "Usage data unavailable"
} }
} catch (e: HermesWsClient.HermesRpcException) { } catch (e: HermesWsClient.HermesRpcException) {
// usage unavailable is non-fatal — do NOT mark the whole // usage unavailable is non-fatal
// feature unavailable; the plan card still renders.
} catch (e: Exception) { } catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e if (e is kotlinx.coroutines.CancellationException) throw e
if (error == null) error = e.message ?: "Failed to load usage" 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 { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
subscription = sub, subscription = sub,
usage = bars, usage = bars,
featureUnavailable = unavailable, 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, errorMessage = if (sub == null) error else null,
) )
} }
} }
} }
}
fun preview(subscriptionTypeId: String) { fun preview(subscriptionTypeId: String) {
viewModelScope.launch { viewModelScope.launch {
@@ -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,28 +52,25 @@ class ConfigViewModel :
} }
fun loadAll() { fun loadAll() {
safeLaunchLoad(
apiCall = {
safeApiCall { ApiClient.hermesApi.getConfig() }
},
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { configData ->
viewModelScope.launch { viewModelScope.launch {
val schemaResult = _uiState.update { it.copy(isLoading = true, errorMessage = null) }
withContext(Dispatchers.IO) { try {
safeApiCall { ApiClient.hermesApi.getConfigSchema() } coroutineScope {
} val configDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfig() } }
val defaultsResult = val schemaDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigSchema() } }
withContext(Dispatchers.IO) { val defaultsDeferred =
safeApiCall { ApiClient.hermesApi.getConfigDefaults() } async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getConfigDefaults() } }
} val rawDeferred = async(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() } }
val configResult = configDeferred.await()
val schemaResult = schemaDeferred.await()
val defaultsResult = defaultsDeferred.await()
val rawResult = rawDeferred.await()
if (configResult is NetworkResult.Success) {
val configData = configResult.data
val schema = (schemaResult as? NetworkResult.Success)?.data val schema = (schemaResult as? NetworkResult.Success)?.data
val defaults = (defaultsResult 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 path = (rawResult as? NetworkResult.Success)?.data?.path
_uiState.update { _uiState.update {
@@ -90,14 +88,19 @@ class ConfigViewModel :
}, },
) )
} }
} } else {
}, val errorMsg = (configResult as? NetworkResult.Failure)?.error?.message ?: "Unknown error"
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: $errorMsg")
} }
}, }
) }
} catch (e: Exception) {
_uiState.update {
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,14 +51,14 @@ 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() } val profilesResult = profilesDeferred.await()
} val activeResult = activeDeferred.await()
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) { if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
_uiState.update { _uiState.update {
@@ -72,11 +74,19 @@ class ProfilesViewModel :
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load profiles/active: Profiles: $profilesError, Active: $activeError", errorMessage =
"Failed to load profiles/active: " +
"Profiles: $profilesError, Active: $activeError",
) )
} }
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, errorMessage = "Failed to load profiles: ${e.message}")
}
}
}
} }
fun selectActiveProfile(name: String) { fun selectActiveProfile(name: String) {
@@ -289,14 +299,13 @@ 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) { val modelsResult = modelsDeferred.await()
safeApiCall { ApiClient.hermesApi.getSkills() } val skillsResult = skillsDeferred.await()
}
if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) { if (modelsResult is NetworkResult.Success && skillsResult is NetworkResult.Success) {
_uiState.update { _uiState.update {
@@ -312,7 +321,18 @@ class ProfilesViewModel :
_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: " +
"Models: $modelsError, Skills: $skillsError",
)
}
}
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoadingBuilderData = false,
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),
)
}
} }