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:
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user