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:
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 -> {
|
||||
|
||||
Reference in new issue
Block a user