Merge pull request #113 from Hy4ri/refactor/issue-79-retrofit-error-handling

fix(#79): centralize retrofit error handling with NetworkResult
This commit is contained in:
M57 authored and GitHub committed 2026-06-19 23:03:21 +03:00
commit ee97be50cb
22 files changed
+743 -738

No files matched your search

@@ -0,0 +1,87 @@
package com.m57.hermescontrol.data.remote
import retrofit2.Response
import java.io.IOException
sealed interface NetworkResult<out T> {
data class Success<out T>(val data: T) : NetworkResult<T>
data class Failure(val error: NetworkError) : NetworkResult<Nothing>
}
sealed interface NetworkError {
val message: String
data class Http(val code: Int, override val message: String) : NetworkError
data class Connection(override val message: String, val cause: IOException) : NetworkError
data class Unknown(override val message: String, val cause: Throwable) : NetworkError
}
fun mapHttpError(code: Int): NetworkError {
val message =
when (code) {
400 -> "Bad Request (HTTP 400): The server could not understand the request."
401 -> "Unauthorized (HTTP 401): Authentication token is invalid or expired."
403 -> "Forbidden (HTTP 403): You do not have permission to access this resource."
404 -> "Not Found (HTTP 404): The requested resource could not be found."
405 -> "Method Not Allowed (HTTP 405)."
408 -> "Request Timeout (HTTP 408): The server timed out waiting for the request."
409 -> "Conflict (HTTP 409): The request conflicts with current server state."
429 -> "Too Many Requests (HTTP 429): Rate limit exceeded."
in 500..599 -> "Server Error (HTTP $code): The server encountered an error."
else -> "HTTP Error $code: Unexpected server response."
}
return NetworkError.Http(code, message)
}
suspend inline fun <reified T> safeApiCall(
retries: Int = 3,
crossinline call: suspend () -> Response<T>,
): NetworkResult<T> {
var lastException: IOException? = null
for (attempt in 0..retries) {
try {
val response = call()
if (response.isSuccessful) {
val body = response.body()
return if (body != null) {
NetworkResult.Success(body)
} else {
@Suppress("UNCHECKED_CAST")
NetworkResult.Success(null as T)
}
} else {
val code = response.code()
if (code in 500..599 && attempt < retries) {
continue
}
return NetworkResult.Failure(mapHttpError(code))
}
} catch (e: IOException) {
lastException = e
if (attempt == retries) {
return NetworkResult.Failure(
NetworkError.Connection(
"Network connection failure: ${e.message ?: "Unknown connection error"}",
e,
),
)
}
} catch (e: Exception) {
return NetworkResult.Failure(
NetworkError.Unknown(
"An unexpected error occurred: ${e.localizedMessage ?: e.message}",
e,
),
)
}
}
return NetworkResult.Failure(
NetworkError.Connection(
"Network connection failure after $retries attempts: ${lastException?.message}",
lastException ?: IOException("Unknown connection error"),
),
)
}
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Achievement import com.m57.hermescontrol.data.model.Achievement
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -25,32 +27,26 @@ class AchievementsViewModel : ViewModel() {
fun loadAchievements() { fun loadAchievements() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getAchievements() }
ApiClient.hermesApi.getAchievements() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
achievements = response.body()?.achievements.orEmpty(), achievements = result.data.achievements.orEmpty(),
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load achievements: HTTP ${response.code()}",
) )
} }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load achievements: ${e.message}", errorMessage = "Failed to load achievements: ${result.error.message}",
) )
}
} }
} }
} }
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.data.model.MessagingPlatformUpdate
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,23 +29,22 @@ class ChannelsViewModel : ViewModel() {
fun loadPlatforms() { fun loadPlatforms() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() }
ApiClient.hermesApi.getMessagingPlatforms() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, platforms = response.body().orEmpty()) } _uiState.update { it.copy(isLoading = false, platforms = result.data.orEmpty()) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load platforms: HTTP ${response.code()}", errorMessage = "Failed to load platforms: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load platforms: ${e.message}") }
} }
} }
} }
@@ -53,19 +54,18 @@ class ChannelsViewModel : ViewModel() {
update: MessagingPlatformUpdate, update: MessagingPlatformUpdate,
) { ) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.configurePlatform(platformId, update) }
ApiClient.hermesApi.configurePlatform(platformId, update) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "$platformId configured successfully") } _uiState.update { it.copy(toastMessage = "$platformId configured successfully") }
loadPlatforms() loadPlatforms()
} else {
_uiState.update { it.copy(toastMessage = "Failed to configure platform: HTTP ${response.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to configure platform: ${e.message}") } _uiState.update { it.copy(toastMessage = "Failed to configure platform: ${result.error.message}") }
}
} }
} }
} }
@@ -8,6 +8,8 @@ import com.m57.hermescontrol.data.local.HermesDatabase
import com.m57.hermescontrol.data.local.toEntity import com.m57.hermescontrol.data.local.toEntity
import com.m57.hermescontrol.data.local.toUiModel import com.m57.hermescontrol.data.local.toUiModel
import com.m57.hermescontrol.data.remote.ApiClient 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.data.ws.HermesWsClient import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods import com.m57.hermescontrol.data.ws.WsMethods
@@ -423,10 +425,13 @@ class ChatViewModel(
"/status" -> { "/status" -> {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) { ApiClient.hermesApi.getStatus() } withContext(Dispatchers.IO) {
if (response.isSuccessful && response.body() != null) { safeApiCall { ApiClient.hermesApi.getStatus() }
val body = response.body()!! }
when (result) {
is NetworkResult.Success -> {
val body = result.data
val platformsStr = val platformsStr =
body.gateway_platforms?.entries?.joinToString("\n") { (k, v) -> body.gateway_platforms?.entries?.joinToString("\n") { (k, v) ->
" • **$k**: ${v.state ?: "Unknown"}${if (v.error_code != null) " (Error: ${v.error_code})" else ""}" " • **$k**: ${v.state ?: "Unknown"}${if (v.error_code != null) " (Error: ${v.error_code})" else ""}"
@@ -444,41 +449,45 @@ class ChatViewModel(
$platformsStr $platformsStr
""".trimIndent() """.trimIndent()
addAssistantMessage(statusText) addAssistantMessage(statusText)
} else {
addAssistantMessage("Failed to retrieve status: HTTP ${response.code()}")
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve status: ${e.message}") addAssistantMessage("Failed to retrieve status: ${result.error.message}")
}
} }
} }
} }
"/sessions" -> { "/sessions" -> {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) { ApiClient.hermesApi.getSessions() } withContext(Dispatchers.IO) {
if (response.isSuccessful && response.body() != null) { safeApiCall { ApiClient.hermesApi.getSessions() }
val body = response.body()!! }
when (result) {
is NetworkResult.Success -> {
val body = result.data
val sessionsStr = val sessionsStr =
body.sessions.joinToString("\n") { s -> body.sessions.joinToString("\n") { s ->
"• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})" "• **${s.title ?: "Untitled"}** (ID: `${s.id}`, Messages: ${s.message_count ?: 0})"
} }
addAssistantMessage("**Sessions List:**\n$sessionsStr") addAssistantMessage("**Sessions List:**\n$sessionsStr")
} else {
addAssistantMessage("Failed to list sessions: HTTP ${response.code()}")
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
addAssistantMessage("Failed to list sessions: ${e.message}") addAssistantMessage("Failed to list sessions: ${result.error.message}")
}
} }
} }
} }
"/stats", "/system" -> { "/stats", "/system" -> {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) { ApiClient.hermesApi.getSystemStats() } withContext(Dispatchers.IO) {
if (response.isSuccessful && response.body() != null) { safeApiCall { ApiClient.hermesApi.getSystemStats() }
val body = response.body()!! }
when (result) {
is NetworkResult.Success -> {
val body = result.data
val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A" val cpuPct = body.cpuPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A" val memPct = body.memoryPercent?.let { String.format("%.1f%%", it) } ?: "N/A"
val uptimeVal = body.uptime ?: "N/A" val uptimeVal = body.uptime ?: "N/A"
@@ -490,11 +499,10 @@ class ChatViewModel(
• **Uptime:** $uptimeVal • **Uptime:** $uptimeVal
""".trimIndent() """.trimIndent()
addAssistantMessage(statsText) addAssistantMessage(statsText)
} else {
addAssistantMessage("Failed to retrieve system stats: HTTP ${response.code()}")
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
addAssistantMessage("Failed to retrieve system stats: ${e.message}") addAssistantMessage("Failed to retrieve system stats: ${result.error.message}")
}
} }
} }
} }
@@ -584,13 +592,13 @@ class ChatViewModel(
private fun loadSessionMessages(sessionId: String) { private fun loadSessionMessages(sessionId: String) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSessionMessages(sessionId) }
ApiClient.hermesApi.getSessionMessages(sessionId) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
val messagesList = response.body()?.messages.orEmpty() val messagesList = result.data.messages.orEmpty()
val chatMessages = val chatMessages =
messagesList.map { msg -> messagesList.map { msg ->
val role = val role =
@@ -618,21 +626,15 @@ class ChatViewModel(
isLoading = false, isLoading = false,
) )
} }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load messages: HTTP ${response.code()}", errorMessage = "Failed to load messages: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load messages: ${e.message}",
)
}
} }
} }
} }
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.UpdateRawConfigRequest import com.m57.hermescontrol.data.model.UpdateRawConfigRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -28,33 +30,27 @@ class ConfigViewModel : ViewModel() {
fun loadRawConfig() { fun loadRawConfig() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getRawConfig() }
ApiClient.hermesApi.getRawConfig() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
path = response.body()?.path, path = result.data.path,
yamlText = response.body()?.yaml, yamlText = result.data.yaml,
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load config: HTTP ${response.code()}",
) )
} }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load config: ${e.message}", errorMessage = "Failed to load config: ${result.error.message}",
) )
}
} }
} }
} }
@@ -63,12 +59,12 @@ class ConfigViewModel : ViewModel() {
fun saveRawConfig(yamlText: String) { fun saveRawConfig(yamlText: String) {
_uiState.update { it.copy(isSaving = true) } _uiState.update { it.copy(isSaving = true) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.updateRawConfig(UpdateRawConfigRequest(yamlText)) }
ApiClient.hermesApi.updateRawConfig(UpdateRawConfigRequest(yamlText)) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isSaving = false, isSaving = false,
@@ -76,21 +72,15 @@ class ConfigViewModel : ViewModel() {
toastMessage = "Configuration saved successfully", toastMessage = "Configuration saved successfully",
) )
} }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isSaving = false, isSaving = false,
toastMessage = "Failed to save config: HTTP ${response.code()}", toastMessage = "Failed to save config: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isSaving = false,
toastMessage = "Failed to save config: ${e.message}",
)
}
} }
} }
} }
@@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -76,43 +79,50 @@ class ConnectViewModel : ViewModel() {
ApiClient.rebuild() ApiClient.rebuild()
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() }
ApiClient.hermesApi.getStatus() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null) it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
} }
} else { }
val code = response.code() is NetworkResult.Failure -> {
val msg = val msg =
when (code) { when (val err = result.error) {
401 -> { is NetworkError.Http -> {
AuthManager.setToken(null) when (err.code) {
"Invalid token (401 Unauthorized)" 401 -> {
AuthManager.setToken(null)
"Invalid token (401 Unauthorized)"
}
403 -> "Access denied (403 Forbidden)"
else -> "Server returned HTTP ${err.code}"
}
} }
is NetworkError.Connection -> {
403 -> { val causeMessage = err.cause.message ?: ""
"Access denied (403 Forbidden)" when {
causeMessage.contains("timeout", true) -> "Connection timed out"
causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?"
causeMessage.contains("resolve", true) -> "Could not resolve host"
else -> "Connection failed: ${err.cause.message}"
}
} }
is NetworkError.Unknown -> {
else -> { val causeMessage = err.cause.message ?: ""
"Server returned HTTP $code" when {
causeMessage.contains("timeout", true) -> "Connection timed out"
causeMessage.contains("refused", true) -> "Connection refused – is Hermes running?"
causeMessage.contains("resolve", true) -> "Could not resolve host"
else -> "Connection failed: ${err.cause.message}"
}
} }
} }
_uiState.update { it.copy(isConnecting = false, errorMessage = msg) } _uiState.update { it.copy(isConnecting = false, errorMessage = msg) }
} }
} catch (e: Exception) {
val msg =
when {
e.message?.contains("timeout", true) == true -> "Connection timed out"
e.message?.contains("refused", true) == true -> "Connection refused – is Hermes running?"
e.message?.contains("resolve", true) == true -> "Could not resolve host"
else -> "Connection failed: ${e.message}"
}
_uiState.update { it.copy(isConnecting = false, errorMessage = msg) }
} }
} }
} }
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.CronJob import com.m57.hermescontrol.data.model.CronJob
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -26,23 +28,22 @@ class CronJobsViewModel : ViewModel() {
fun loadCronJobs() { fun loadCronJobs() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getCronJobs() }
ApiClient.hermesApi.getCronJobs() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, jobs = response.body().orEmpty()) } _uiState.update { it.copy(isLoading = false, jobs = result.data.orEmpty()) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load cron jobs: HTTP ${response.code()}", errorMessage = "Failed to load cron jobs: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load cron jobs: ${e.message}") }
} }
} }
} }
@@ -59,16 +60,12 @@ class CronJobsViewModel : ViewModel() {
) )
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.pauseCronJob(id) }
ApiClient.hermesApi.pauseCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to pause cron job: HTTP ${response.code()}")
} }
} catch (e: Exception) { if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to pause cron job: ${e.message}") revertJobs(originalJobs, "Failed to pause cron job: ${result.error.message}")
} }
} }
} }
@@ -85,34 +82,29 @@ class CronJobsViewModel : ViewModel() {
) )
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.resumeCronJob(id) }
ApiClient.hermesApi.resumeCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to resume cron job: HTTP ${response.code()}")
} }
} catch (e: Exception) { if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to resume cron job: ${e.message}") revertJobs(originalJobs, "Failed to resume cron job: ${result.error.message}")
} }
} }
} }
fun triggerCronJob(id: String) { fun triggerCronJob(id: String) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
ApiClient.hermesApi.triggerCronJob(id) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Job triggered successfully") } _uiState.update { it.copy(toastMessage = "Job triggered successfully") }
} else { }
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: HTTP ${response.code()}") } is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${result.error.message}") }
} }
} catch (e: Exception) {
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${e.message}") }
} }
} }
} }
@@ -124,16 +116,12 @@ class CronJobsViewModel : ViewModel() {
state.copy(jobs = state.jobs.filter { it.id != id }) state.copy(jobs = state.jobs.filter { it.id != id })
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.deleteCronJob(id) }
ApiClient.hermesApi.deleteCronJob(id)
}
if (!response.isSuccessful) {
revertJobs(originalJobs, "Failed to delete cron job: HTTP ${response.code()}")
} }
} catch (e: Exception) { if (result is NetworkResult.Failure) {
revertJobs(originalJobs, "Failed to delete cron job: ${e.message}") revertJobs(originalJobs, "Failed to delete cron job: ${result.error.message}")
} }
} }
} }
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,53 +29,47 @@ class GatewayViewModel : ViewModel() {
fun loadStatus() { fun loadStatus() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() }
ApiClient.hermesApi.getStatus() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, status = response.body()) } _uiState.update { it.copy(isLoading = false, status = result.data) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load status: HTTP ${response.code()}", errorMessage = "Failed to load status: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load status: ${e.message}",
)
}
} }
} }
} }
fun startGateway() { fun startGateway() {
runGatewayAction("start") { ApiClient.hermesApi.startGateway() } runGatewayAction("start") { safeApiCall { ApiClient.hermesApi.startGateway() } }
} }
fun stopGateway() { fun stopGateway() {
runGatewayAction("stop") { ApiClient.hermesApi.stopGateway() } runGatewayAction("stop") { safeApiCall { ApiClient.hermesApi.stopGateway() } }
} }
fun restartGateway() { fun restartGateway() {
runGatewayAction("restart") { ApiClient.hermesApi.restartGateway() } runGatewayAction("restart") { safeApiCall { ApiClient.hermesApi.restartGateway() } }
} }
private fun runGatewayAction( private fun runGatewayAction(
actionName: String, actionName: String,
apiCall: suspend () -> retrofit2.Response<Unit>, apiCall: suspend () -> NetworkResult<Unit>,
) { ) {
_uiState.update { it.copy(isActionRunning = true) } _uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch { viewModelScope.launch {
try { val result = withContext(Dispatchers.IO) { apiCall() }
val response = withContext(Dispatchers.IO) { apiCall() } when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
@@ -81,21 +77,15 @@ class GatewayViewModel : ViewModel() {
) )
} }
loadStatus() loadStatus()
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
toastMessage = "Failed to $actionName gateway: HTTP ${response.code()}", toastMessage = "Failed to $actionName gateway: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Failed to $actionName gateway: ${e.message}",
)
}
} }
} }
} }
@@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -31,34 +33,28 @@ class KanbanViewModel : ViewModel() {
fun loadBoards() { fun loadBoards() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getKanbanBoards() }
ApiClient.hermesApi.getKanbanBoards() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
val boards = response.body()?.boards.orEmpty() val boards = result.data.boards.orEmpty()
_uiState.update { it.copy(isLoading = false, boards = boards) } _uiState.update { it.copy(isLoading = false, boards = boards) }
val currentSlug = response.body()?.current val currentSlug = result.data.current
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull() val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
if (currentBoard != null) { if (currentBoard != null) {
selectBoard(currentBoard) selectBoard(currentBoard)
} }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load Kanban boards: HTTP ${response.code()}", errorMessage = "Failed to load Kanban boards: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${e.message}",
)
}
} }
} }
} }
@@ -66,41 +62,44 @@ class KanbanViewModel : ViewModel() {
fun selectBoard(board: KanbanBoard) { fun selectBoard(board: KanbanBoard) {
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) } _uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val switchResult =
// Switch current board slug
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ApiClient.hermesApi.switchKanbanBoard(board.id) safeApiCall { ApiClient.hermesApi.switchKanbanBoard(board.id) }
} }
if (switchResult is NetworkResult.Failure) {
val response =
withContext(Dispatchers.IO) {
ApiClient.hermesApi.getKanbanBoard()
}
if (response.isSuccessful) {
val body = response.body()
val allTasks = body?.columns?.flatMap { it.tasks }.orEmpty()
_uiState.update {
it.copy(
isLoading = false,
columns = body?.columns.orEmpty(),
tasks = allTasks,
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: HTTP ${response.code()}",
)
}
}
} catch (e: Exception) {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${e.message}", errorMessage = "Failed to switch Kanban board: ${switchResult.error.message}",
) )
} }
return@launch
}
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
when (result) {
is NetworkResult.Success -> {
val body = result.data
val allTasks = body.columns.flatMap { it.tasks }
_uiState.update {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
}
}
} }
} }
} }
@@ -112,9 +111,9 @@ class KanbanViewModel : ViewModel() {
) { ) {
val board = _uiState.value.selectedBoard ?: return val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall {
ApiClient.hermesApi.createKanbanTask( ApiClient.hermesApi.createKanbanTask(
board = board.id, board = board.id,
task = task =
@@ -124,14 +123,15 @@ class KanbanViewModel : ViewModel() {
), ),
) )
} }
if (response.isSuccessful) { }
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task created successfully") } _uiState.update { it.copy(toastMessage = "Task created successfully") }
selectBoard(board) selectBoard(board)
} else {
_uiState.update { it.copy(toastMessage = "Failed to create task: HTTP ${response.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to create task: ${e.message}") } _uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") }
}
} }
} }
} }
@@ -154,16 +154,12 @@ class KanbanViewModel : ViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, mapOf("status" to newStatus)) }
ApiClient.hermesApi.updateKanbanTask(task.id, mapOf("status" to newStatus))
}
if (!response.isSuccessful) {
revertTaskMove(task.id, originalStatus, "Failed to move task: HTTP ${response.code()}")
} }
} catch (e: Exception) { if (result is NetworkResult.Failure) {
revertTaskMove(task.id, originalStatus, "Failed to move task: ${e.message}") revertTaskMove(task.id, originalStatus, "Failed to move task: ${result.error.message}")
} }
} }
} }
@@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.EnvVarConfig
import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest
import com.m57.hermescontrol.data.model.EnvVarUpdate import com.m57.hermescontrol.data.model.EnvVarUpdate
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -29,51 +31,49 @@ class KeysViewModel : ViewModel() {
fun loadKeys(reveal: Boolean = false) { fun loadKeys(reveal: Boolean = false) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getEnvVars() }
ApiClient.hermesApi.getEnvVars() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
envVars = response.body().orEmpty(), envVars = result.data.orEmpty(),
) )
} }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load keys: HTTP ${response.code()}", errorMessage = "Failed to load keys: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load keys: ${e.message}") }
} }
} }
} }
fun revealKey(key: String) { fun revealKey(key: String) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
ApiClient.hermesApi.revealEnvVar(EnvVarRevealRequest(key)) }
} when (result) {
if (response.isSuccessful && response.body() != null) { is NetworkResult.Success -> {
val body = response.body()!! val body = result.data
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
revealedValues = state.revealedValues + (body.key to body.value), revealedValues = state.revealedValues + (body.key to body.value),
) )
} }
} else {
_uiState.update { it.copy(toastMessage = "Failed to reveal key: HTTP ${response.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to reveal key: ${e.message}") } _uiState.update { it.copy(toastMessage = "Failed to reveal key: ${result.error.message}") }
}
} }
} }
} }
@@ -91,19 +91,18 @@ class KeysViewModel : ViewModel() {
value: String, value: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key, value)) }
ApiClient.hermesApi.updateEnvVar(EnvVarUpdate(key, value)) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Key updated successfully") } _uiState.update { it.copy(toastMessage = "Key updated successfully") }
loadKeys() loadKeys()
} else {
_uiState.update { it.copy(toastMessage = "Failed to update key: HTTP ${response.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to update key: ${e.message}") } _uiState.update { it.copy(toastMessage = "Failed to update key: ${result.error.message}") }
}
} }
} }
} }
@@ -3,6 +3,8 @@ package com.m57.hermescontrol.ui.logs
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -25,25 +27,24 @@ class LogsViewModel : ViewModel() {
fun loadLogs() { fun loadLogs() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getLogs() }
ApiClient.hermesApi.getLogs() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
val body = response.body() val body = result.data
val logsList = body?.lines ?: body?.logs ?: emptyList() val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) } _uiState.update { it.copy(isLoading = false, logs = logsList) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load logs: HTTP ${response.code()}", errorMessage = "Failed to load logs: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load logs: ${e.message}") }
} }
} }
} }
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.data.model.McpServer
import com.m57.hermescontrol.data.model.McpServerToggleRequest import com.m57.hermescontrol.data.model.McpServerToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -28,32 +30,26 @@ class McpServersViewModel : ViewModel() {
fun loadServers() { fun loadServers() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getMcpServers() }
ApiClient.hermesApi.getMcpServers() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
servers = response.body()?.servers.orEmpty(), servers = result.data.servers.orEmpty(),
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load MCP servers: HTTP ${response.code()}",
) )
} }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load MCP servers: ${e.message}", errorMessage = "Failed to load MCP servers: ${result.error.message}",
) )
}
} }
} }
} }
@@ -73,22 +69,23 @@ class McpServersViewModel : ViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall {
ApiClient.hermesApi.toggleMcpServer(server.name, McpServerToggleRequest(targetEnabled)) ApiClient.hermesApi.toggleMcpServer(server.name, McpServerToggleRequest(targetEnabled))
} }
if (!response.isSuccessful) { }
revertToggle(server.name, originalEnabled, "Failed to toggle server: HTTP ${response.code()}") when (result) {
} else { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
toastMessage = "Server '${server.name}' ${if (targetEnabled) "enabled" else "disabled"}", toastMessage = "Server '${server.name}' ${if (targetEnabled) "enabled" else "disabled"}",
) )
} }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
revertToggle(server.name, originalEnabled, "Failed to toggle server: ${e.message}") revertToggle(server.name, originalEnabled, "Failed to toggle server: ${result.error.message}")
}
} }
} }
} }
@@ -96,33 +93,27 @@ class McpServersViewModel : ViewModel() {
fun testServer(name: String) { fun testServer(name: String) {
_uiState.update { it.copy(isActionRunning = true) } _uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.testMcpServer(name) }
ApiClient.hermesApi.testMcpServer(name) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
toastMessage = "Server '$name' tested successfully", toastMessage = "Server '$name' tested successfully",
) )
} }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
toastMessage = "Server '$name' test failed: HTTP ${response.code()}", toastMessage = "Server '$name' test failed: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Server '$name' test failed: ${e.message}",
)
}
} }
} }
} }
@@ -130,12 +121,12 @@ class McpServersViewModel : ViewModel() {
fun deleteServer(name: String) { fun deleteServer(name: String) {
_uiState.update { it.copy(isActionRunning = true) } _uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.deleteMcpServer(name) }
ApiClient.hermesApi.deleteMcpServer(name) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
@@ -143,21 +134,15 @@ class McpServersViewModel : ViewModel() {
) )
} }
loadServers() loadServers()
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
toastMessage = "Failed to delete server: HTTP ${response.code()}", toastMessage = "Failed to delete server: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Failed to delete server: ${e.message}",
)
}
} }
} }
} }
@@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileInfo
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -29,26 +31,30 @@ class ModelViewModel : ViewModel() {
fun loadModelOptions() { fun loadModelOptions() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val responseResult =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getModelOptions() }
ApiClient.hermesApi.getModelOptions() }
} val activeProfileNameResResult =
val activeProfileNameRes = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() }
ApiClient.hermesApi.getActiveProfile() }
} val profilesResResult =
val profilesRes = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getProfiles() }
ApiClient.hermesApi.getProfiles() }
}
if (response.isSuccessful) { when (responseResult) {
is NetworkResult.Success -> {
val activeName = val activeName =
if (activeProfileNameRes.isSuccessful) activeProfileNameRes.body()?.active else null if (activeProfileNameResResult is NetworkResult.Success) {
activeProfileNameResResult.data.active
} else {
null
}
val activeProfile = val activeProfile =
if (profilesRes.isSuccessful && activeName != null) { if (profilesResResult is NetworkResult.Success && activeName != null) {
profilesRes.body()?.profiles?.find { it.name == activeName } profilesResResult.data.profiles.find { it.name == activeName }
} else { } else {
null null
} }
@@ -56,25 +62,19 @@ class ModelViewModel : ViewModel() {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
providers = response.body()?.providers.orEmpty(), providers = responseResult.data.providers.orEmpty(),
activeProfile = activeProfile, activeProfile = activeProfile,
) )
} }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load model options: HTTP ${response.code()}", errorMessage = "Failed to load model options: ${responseResult.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load model options: ${e.message}",
)
}
} }
} }
} }
@@ -84,40 +84,45 @@ class ModelViewModel : ViewModel() {
modelName: String, modelName: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
try { val activeProfileNameResResult =
val activeProfileNameRes = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getActiveProfile() }
ApiClient.hermesApi.getActiveProfile() }
} when (activeProfileNameResResult) {
if (!activeProfileNameRes.isSuccessful) { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
toastMessage = "Failed to fetch active profile: HTTP ${activeProfileNameRes.code()}", toastMessage =
"Failed to fetch active profile: ${activeProfileNameResResult.error.message}",
) )
} }
return@launch return@launch
} }
val activeProfileName = activeProfileNameRes.body()?.active is NetworkResult.Success -> {
if (activeProfileName == null) { val activeProfileName = activeProfileNameResResult.data.active
_uiState.update { it.copy(toastMessage = "No active profile found") } val updateResResult =
return@launch withContext(Dispatchers.IO) {
} safeApiCall {
ApiClient.hermesApi.updateProfileModel(
val updateRes = activeProfileName,
withContext(Dispatchers.IO) { UpdateProfileModelRequest(providerSlug, modelName),
ApiClient.hermesApi.updateProfileModel( )
activeProfileName, }
UpdateProfileModelRequest(providerSlug, modelName), }
) when (updateResResult) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") }
loadModelOptions()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
toastMessage = "Failed to set model: ${updateResResult.error.message}",
)
}
}
} }
if (updateRes.isSuccessful) {
_uiState.update { it.copy(toastMessage = "Successfully set model to $modelName") }
loadModelOptions()
} else {
_uiState.update { it.copy(toastMessage = "Failed to set model: HTTP ${updateRes.code()}") }
} }
} catch (e: Exception) {
_uiState.update { it.copy(toastMessage = "Failed to set model: ${e.message}") }
} }
} }
} }
@@ -6,6 +6,8 @@ import com.m57.hermescontrol.data.model.PairingApproveRequest
import com.m57.hermescontrol.data.model.PairingResponse import com.m57.hermescontrol.data.model.PairingResponse
import com.m57.hermescontrol.data.model.PairingRevokeRequest import com.m57.hermescontrol.data.model.PairingRevokeRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -29,28 +31,22 @@ class PairingViewModel : ViewModel() {
fun loadPairing() { fun loadPairing() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPairing() }
ApiClient.hermesApi.getPairing() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, pairing = response.body()) } _uiState.update { it.copy(isLoading = false, pairing = result.data) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load pairing info: HTTP ${response.code()}", errorMessage = "Failed to load pairing info: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load pairing info: ${e.message}",
)
}
} }
} }
} }
@@ -60,7 +56,7 @@ class PairingViewModel : ViewModel() {
code: String, code: String,
) { ) {
runAction("Approve") { runAction("Approve") {
ApiClient.hermesApi.approvePairing(PairingApproveRequest(platform, code)) safeApiCall { ApiClient.hermesApi.approvePairing(PairingApproveRequest(platform, code)) }
} }
} }
@@ -69,25 +65,25 @@ class PairingViewModel : ViewModel() {
userId: String, userId: String,
) { ) {
runAction("Revoke") { runAction("Revoke") {
ApiClient.hermesApi.revokePairing(PairingRevokeRequest(platform, userId)) safeApiCall { ApiClient.hermesApi.revokePairing(PairingRevokeRequest(platform, userId)) }
} }
} }
fun clearPending() { fun clearPending() {
runAction("Clear Pending") { runAction("Clear Pending") {
ApiClient.hermesApi.clearPendingPairing() safeApiCall { ApiClient.hermesApi.clearPendingPairing() }
} }
} }
private fun runAction( private fun runAction(
actionName: String, actionName: String,
apiCall: suspend () -> retrofit2.Response<Unit>, apiCall: suspend () -> NetworkResult<Unit>,
) { ) {
_uiState.update { it.copy(isActionRunning = true) } _uiState.update { it.copy(isActionRunning = true) }
viewModelScope.launch { viewModelScope.launch {
try { val result = withContext(Dispatchers.IO) { apiCall() }
val response = withContext(Dispatchers.IO) { apiCall() } when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
@@ -95,21 +91,15 @@ class PairingViewModel : ViewModel() {
) )
} }
loadPairing() loadPairing()
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isActionRunning = false, isActionRunning = false,
toastMessage = "Action '$actionName' failed: HTTP ${response.code()}", toastMessage = "Action '$actionName' failed: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isActionRunning = false,
toastMessage = "Action '$actionName' failed: ${e.message}",
)
}
} }
} }
} }
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -26,24 +28,23 @@ class PluginsViewModel : ViewModel() {
fun loadPlugins() { fun loadPlugins() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getPlugins() }
ApiClient.hermesApi.getPlugins() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
val plugins = response.body()?.plugins.orEmpty() val plugins = result.data.plugins.orEmpty()
_uiState.update { it.copy(isLoading = false, plugins = plugins) } _uiState.update { it.copy(isLoading = false, plugins = plugins) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load plugins: HTTP ${response.code()}", errorMessage = "Failed to load plugins: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load plugins: ${e.message}") }
} }
} }
} }
@@ -67,80 +68,75 @@ class PluginsViewModel : ViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { if (targetEnabled) {
if (targetEnabled) { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) }
ApiClient.hermesApi.enablePlugin(plugin.name) } else {
} else { safeApiCall { ApiClient.hermesApi.disablePlugin(plugin.name) }
ApiClient.hermesApi.disablePlugin(plugin.name)
}
} }
if (!response.isSuccessful) {
revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: HTTP ${response.code()}")
} }
} catch (e: Exception) { if (result is NetworkResult.Failure) {
revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: ${e.message}") revertPluginToggle(plugin.name, originalEnabled, "Failed to toggle plugin: ${result.error.message}")
} }
} }
} }
fun installPlugin(name: String) { fun installPlugin(name: String) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall {
ApiClient.hermesApi.installPlugin( ApiClient.hermesApi.installPlugin(
com.m57.hermescontrol.data.model com.m57.hermescontrol.data.model
.AgentPluginInstallBody(name), .AgentPluginInstallBody(name),
) )
} }
if (response.isSuccessful) { }
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin installed successfully") } _uiState.update { it.copy(toastMessage = "Plugin installed successfully") }
loadPlugins() loadPlugins()
} else {
_uiState.update { it.copy(toastMessage = "Failed to install plugin: HTTP ${response.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to install plugin: ${e.message}") } _uiState.update { it.copy(toastMessage = "Failed to install plugin: ${result.error.message}") }
}
} }
} }
} }
fun uninstallPlugin(name: String) { fun uninstallPlugin(name: String) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) }
ApiClient.hermesApi.uninstallPlugin(name) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } _uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") }
loadPlugins() loadPlugins()
} else {
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: HTTP ${response.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${e.message}") } _uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") }
}
} }
} }
} }
fun updatePlugin(name: String) { fun updatePlugin(name: String) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.updatePlugin(name) }
ApiClient.hermesApi.updatePlugin(name) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Plugin updated successfully") } _uiState.update { it.copy(toastMessage = "Plugin updated successfully") }
loadPlugins() loadPlugins()
} else {
_uiState.update { it.copy(toastMessage = "Failed to update plugin: HTTP ${response.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to update plugin: ${e.message}") } _uiState.update { it.copy(toastMessage = "Failed to update plugin: ${result.error.message}") }
}
} }
} }
} }
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.data.model.SetActiveProfileRequest
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -32,33 +34,30 @@ class ProfilesViewModel : ViewModel() {
fun loadProfiles() { fun loadProfiles() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val profilesResult =
val profilesRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getProfiles() } withContext(Dispatchers.IO) {
val activeRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getActiveProfile() } safeApiCall { ApiClient.hermesApi.getProfiles() }
if (profilesRes.isSuccessful && activeRes.isSuccessful) {
_uiState.update {
it.copy(
isLoading = false,
profiles = profilesRes.body()?.profiles.orEmpty(),
activeProfileName = activeRes.body()?.active,
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage =
"Failed to load profiles/active: HTTP " +
"${profilesRes.code()} / ${activeRes.code()}",
)
}
} }
} catch (e: Exception) { val activeResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getActiveProfile() }
}
if (profilesResult is NetworkResult.Success && activeResult is NetworkResult.Success) {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Error loading profiles: ${e.message}", 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",
) )
} }
} }
@@ -71,29 +70,23 @@ class ProfilesViewModel : ViewModel() {
_uiState.update { it.copy(activeProfileName = name) } _uiState.update { it.copy(activeProfileName = name) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val res = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name)) }
} when (result) {
if (res.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Switched to profile $name") } _uiState.update { it.copy(toastMessage = "Switched to profile $name") }
loadProfiles() loadProfiles()
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
activeProfileName = originalActive, activeProfileName = originalActive,
toastMessage = "Failed to switch profile: HTTP ${res.code()}", toastMessage = "Failed to switch profile: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
activeProfileName = originalActive,
toastMessage = "Failed to switch profile: ${e.message}",
)
}
} }
} }
} }
@@ -101,32 +94,26 @@ class ProfilesViewModel : ViewModel() {
fun loadSoul(profileName: String) { fun loadSoul(profileName: String) {
_uiState.update { it.copy(isLoadingSoul = true, selectedSoulContent = null) } _uiState.update { it.copy(isLoadingSoul = true, selectedSoulContent = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val res = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getProfileSoul(profileName) }
ApiClient.hermesApi.getProfileSoul(profileName) }
} when (result) {
if (res.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoadingSoul = false, isLoadingSoul = false,
selectedSoulContent = res.body()?.content ?: "", selectedSoulContent = result.data.content,
)
}
} else {
_uiState.update {
it.copy(
isLoadingSoul = false,
toastMessage = "Failed to load soul: HTTP ${res.code()}",
) )
} }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoadingSoul = false, isLoadingSoul = false,
toastMessage = "Failed to load soul: ${e.message}", toastMessage = "Failed to load soul: ${result.error.message}",
) )
}
} }
} }
} }
@@ -137,12 +124,17 @@ class ProfilesViewModel : ViewModel() {
content: String, content: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val res = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall {
ApiClient.hermesApi.updateProfileSoul(profileName, UpdateProfileSoulRequest(content)) ApiClient.hermesApi.updateProfileSoul(
profileName,
UpdateProfileSoulRequest(content),
)
} }
if (res.isSuccessful) { }
when (result) {
is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
selectedSoulContent = null, selectedSoulContent = null,
@@ -150,19 +142,14 @@ class ProfilesViewModel : ViewModel() {
) )
} }
loadProfiles() loadProfiles()
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
toastMessage = "Failed to save soul: HTTP ${res.code()}", toastMessage = "Failed to save soul: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
toastMessage = "Failed to save soul: ${e.message}",
)
}
} }
} }
} }
@@ -173,19 +160,27 @@ class ProfilesViewModel : ViewModel() {
model: String, model: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val res = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall {
ApiClient.hermesApi.updateProfileModel(profileName, UpdateProfileModelRequest(provider, model)) ApiClient.hermesApi.updateProfileModel(
profileName,
UpdateProfileModelRequest(provider, model),
)
} }
if (res.isSuccessful) { }
when (result) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Model settings updated") } _uiState.update { it.copy(toastMessage = "Model settings updated") }
loadProfiles() loadProfiles()
} else {
_uiState.update { it.copy(toastMessage = "Failed to update model settings: HTTP ${res.code()}") }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to update model settings: ${e.message}") } _uiState.update {
it.copy(
toastMessage = "Failed to update model settings: ${result.error.message}",
)
}
}
} }
} }
} }
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient 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.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -144,30 +146,24 @@ class SettingsViewModel : ViewModel() {
_uiState.update { it.copy(isTesting = true, testResult = null) } _uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getStatus() }
ApiClient.hermesApi.getStatus() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy(isTesting = false, testResult = "✅ Connected successfully") it.copy(isTesting = false, testResult = "✅ Connected successfully")
} }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isTesting = false, isTesting = false,
testResult = "❌ HTTP ${response.code()}: ${response.message()}", testResult = "❌ ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isTesting = false,
testResult = "❌ ${e.message ?: "Connection failed"}",
)
}
} }
} }
} }
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Skill import com.m57.hermescontrol.data.model.Skill
import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.ToggleSkillRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,23 +29,22 @@ class SkillsViewModel : ViewModel() {
fun loadSkills() { fun loadSkills() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSkills() }
ApiClient.hermesApi.getSkills() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, skills = response.body().orEmpty()) } _uiState.update { it.copy(isLoading = false, skills = result.data.orEmpty()) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load skills: HTTP ${response.code()}", errorMessage = "Failed to load skills: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load skills: ${e.message}") }
} }
} }
} }
@@ -63,16 +64,12 @@ class SkillsViewModel : ViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled)) }
ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled))
}
if (!response.isSuccessful) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: HTTP ${response.code()}")
} }
} catch (e: Exception) { if (result is NetworkResult.Failure) {
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${e.message}") revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${result.error.message}")
} }
} }
} }
@@ -7,6 +7,8 @@ import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.model.DoctorResponse import com.m57.hermescontrol.data.model.DoctorResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse import com.m57.hermescontrol.data.model.SystemStatsResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -34,70 +36,60 @@ class SystemViewModel : ViewModel() {
fun loadSystemData() { fun loadSystemData() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val statsResponse = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getSystemStats() }
ApiClient.hermesApi.getSystemStats() }
} when (result) {
is NetworkResult.Success -> {
if (statsResponse.isSuccessful) {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
stats = statsResponse.body(), stats = result.data,
) )
} }
// Doctor is optional; many servers do not expose it. // Doctor is optional; many servers do not expose it.
loadDoctorReport() loadDoctorReport()
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load system stats: HTTP ${statsResponse.code()}", errorMessage = "Failed to load system stats: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load system stats: ${e.message}",
)
}
} }
} }
} }
private fun loadDoctorReport() { private fun loadDoctorReport() {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val doctorResponse = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.runDoctor() }
ApiClient.hermesApi.runDoctor()
}
if (doctorResponse.isSuccessful) {
_uiState.update { it.copy(doctorReport = doctorResponse.body()) }
} }
} catch (e: Exception) { if (result is NetworkResult.Success) {
if (BuildConfig.DEBUG) Log.w(TAG, "doctor endpoint unavailable", e) _uiState.update { it.copy(doctorReport = result.data) }
} else if (result is NetworkResult.Failure && BuildConfig.DEBUG) {
Log.w(TAG, "doctor endpoint unavailable: ${result.error.message}")
} }
} }
} }
fun triggerBackup() { fun triggerBackup() {
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.triggerBackup() }
ApiClient.hermesApi.triggerBackup() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Backup triggered successfully") } _uiState.update { it.copy(toastMessage = "Backup triggered successfully") }
} else { }
_uiState.update { it.copy(toastMessage = "Failed to trigger backup: HTTP ${response.code()}") } is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${result.error.message}") }
} }
} catch (e: Exception) {
_uiState.update { it.copy(toastMessage = "Failed to trigger backup: ${e.message}") }
} }
} }
} }
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.Toolset import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest import com.m57.hermescontrol.data.model.ToolsetToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -27,28 +29,22 @@ class ToolsetsViewModel : ViewModel() {
fun loadToolsets() { fun loadToolsets() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getToolsets() }
ApiClient.hermesApi.getToolsets() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { it.copy(isLoading = false, toolsets = response.body().orEmpty()) } _uiState.update { it.copy(isLoading = false, toolsets = result.data.orEmpty()) }
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load toolsets: HTTP ${response.code()}", errorMessage = "Failed to load toolsets: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load toolsets: ${e.message}",
)
}
} }
} }
} }
@@ -68,16 +64,12 @@ class ToolsetsViewModel : ViewModel() {
} }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled)) }
ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled))
}
if (!response.isSuccessful) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: HTTP ${response.code()}")
} }
} catch (e: Exception) { if (result is NetworkResult.Failure) {
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${e.message}") revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${result.error.message}")
} }
} }
} }
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.WebhookSubscription import com.m57.hermescontrol.data.model.WebhookSubscription
import com.m57.hermescontrol.data.model.WebhooksToggleRequest import com.m57.hermescontrol.data.model.WebhooksToggleRequest
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -30,35 +32,29 @@ class WebhooksViewModel : ViewModel() {
fun loadWebhooks() { fun loadWebhooks() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.getWebhooks() }
ApiClient.hermesApi.getWebhooks() }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
val body = response.body() val body = result.data
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
enabled = body?.enabled ?: false, enabled = body.enabled,
baseUrl = body?.base_url, baseUrl = body.base_url,
subscriptions = body?.subscriptions.orEmpty(), subscriptions = body.subscriptions.orEmpty(),
)
}
} else {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load webhooks: HTTP ${response.code()}",
) )
} }
} }
} catch (e: Exception) { is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
errorMessage = "Failed to load webhooks: ${e.message}", errorMessage = "Failed to load webhooks: ${result.error.message}",
) )
}
} }
} }
} }
@@ -69,33 +65,27 @@ class WebhooksViewModel : ViewModel() {
_uiState.update { it.copy(enabled = targetEnabled) } _uiState.update { it.copy(enabled = targetEnabled) }
viewModelScope.launch { viewModelScope.launch {
try { val result =
val response = withContext(Dispatchers.IO) {
withContext(Dispatchers.IO) { safeApiCall { ApiClient.hermesApi.toggleWebhooks(WebhooksToggleRequest(targetEnabled)) }
ApiClient.hermesApi.toggleWebhooks(WebhooksToggleRequest(targetEnabled)) }
} when (result) {
if (response.isSuccessful) { is NetworkResult.Success -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}", toastMessage = "Webhooks global status ${if (targetEnabled) "enabled" else "disabled"}",
) )
} }
loadWebhooks() loadWebhooks()
} else { }
is NetworkResult.Failure -> {
_uiState.update { _uiState.update {
it.copy( it.copy(
enabled = originalEnabled, enabled = originalEnabled,
toastMessage = "Failed to toggle webhooks: HTTP ${response.code()}", toastMessage = "Failed to toggle webhooks: ${result.error.message}",
) )
} }
} }
} catch (e: Exception) {
_uiState.update {
it.copy(
enabled = originalEnabled,
toastMessage = "Failed to toggle webhooks: ${e.message}",
)
}
} }
} }
} }
@@ -143,6 +143,14 @@ class ConnectViewModelTest {
val mockResponse = mockk<Response<StatusResponse>>() val mockResponse = mockk<Response<StatusResponse>>()
every { mockResponse.isSuccessful } returns true every { mockResponse.isSuccessful } returns true
every { mockResponse.body() } returns
StatusResponse(
version = "1.0",
gateway_running = true,
active_sessions = 1,
auth_required = false,
gateway_platforms = emptyMap(),
)
coEvery { mockApiService.getStatus() } returns mockResponse coEvery { mockApiService.getStatus() } returns mockResponse
viewModel.connect() viewModel.connect()