Refactor: Extract boilerplate: safeLaunchLoad, clearToast, ToastEffect (CODE-21/22/23) (#312)

- Extracted `safeLaunchLoad` inline extension function to `ViewModelExt.kt` to encapsulate API call success/failure handling logic and replaced usages across 18 viewmodels.
- Extracted `ToastHost` interface to standardize `clearToast()` behavior across all ViewModels displaying toasts.
- Extracted `ToastEffect` composable to `SharedComponents.kt` and applied it to 16 screens, reducing repetitive side effect code blocks.

Fixes #286
This commit is contained in:
M57 authored and GitHub committed 2026-06-22 23:54:42 +03:00
1 parent 6abfabdac0
commit 98c02879b4
36 files changed
+427 -585

No files matched your search

@@ -1,18 +1,14 @@
package com.m57.hermescontrol.ui.achievements package com.m57.hermescontrol.ui.achievements
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
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 com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import com.m57.hermescontrol.ui.common.safeLaunchLoad
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
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class AchievementsUiState( data class AchievementsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -25,31 +21,25 @@ class AchievementsViewModel : ViewModel() {
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow() val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
fun loadAchievements() { fun loadAchievements() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getAchievements() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getAchievements() } _uiState.update {
it.copy(
isLoading = false,
achievements = data.achievements.orEmpty(),
)
} }
when (result) { },
is NetworkResult.Success -> { onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
achievements = result.data.achievements.orEmpty(), errorMessage = "Failed to load achievements: $errorMsg",
) )
}
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load achievements: ${result.error.message}",
)
}
}
}
}
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.channels package com.m57.hermescontrol.ui.channels
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -27,7 +26,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -39,6 +37,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -49,7 +48,6 @@ fun ChannelsScreen(
viewModel: ChannelsViewModel = viewModel { ChannelsViewModel() }, viewModel: ChannelsViewModel = viewModel { ChannelsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -64,12 +62,7 @@ fun ChannelsScreen(
viewModel.loadPlatforms() viewModel.loadPlatforms()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_channels)) }, title = { Text(stringResource(R.string.screen_channels)) },
@@ -1,19 +1,16 @@
package com.m57.hermescontrol.ui.channels package com.m57.hermescontrol.ui.channels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
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 com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
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
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ChannelsUiState( data class ChannelsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -22,67 +19,55 @@ data class ChannelsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class ChannelsViewModel : ViewModel() { class ChannelsViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ChannelsUiState()) private val _uiState = MutableStateFlow(ChannelsUiState())
val uiState: StateFlow<ChannelsUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChannelsUiState> = _uiState.asStateFlow()
fun loadPlatforms() { fun loadPlatforms() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } _uiState.update { it.copy(isLoading = false, platforms = data?.platforms.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load platforms: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
_uiState.update { it.copy(isLoading = false, platforms = result.data?.platforms.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load platforms: ${result.error.message}",
)
}
}
}
}
} }
fun configurePlatform( fun configurePlatform(
platformId: String, platformId: String,
update: MessagingPlatformUpdate, update: MessagingPlatformUpdate,
) { ) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.configurePlatform(platformId, update) } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.configurePlatform(platformId, update) } val message = "$platformId configured successfully — restart the gateway for changes to take effect"
_uiState.update {
it.copy(
isLoading = false,
toastMessage = message,
)
} }
when (result) { loadPlatforms()
is NetworkResult.Success -> { },
val message = "$platformId configured successfully — restart the gateway for changes to take effect" onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
toastMessage = message, errorMessage = "Failed to configure platform: $errorMsg",
) toastMessage = "Failed to configure platform: $errorMsg",
} )
loadPlatforms()
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to configure platform: ${result.error.message}",
toastMessage = "Failed to configure platform: ${result.error.message}",
)
}
}
}
}
} }
fun configurePlatform( fun configurePlatform(
@@ -92,7 +77,7 @@ class ChannelsViewModel : ViewModel() {
configurePlatform(platformId, MessagingPlatformUpdate(env = config)) configurePlatform(platformId, MessagingPlatformUpdate(env = config))
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -343,3 +343,19 @@ fun InfoRow(
) )
} }
} }
// ── ToastEffect — helper for one-shot toast messages ───────────────────
@Composable
fun ToastEffect(
toastMessage: String?,
onClearToast: () -> Unit,
) {
val context = androidx.compose.ui.platform.LocalContext.current
androidx.compose.runtime.LaunchedEffect(toastMessage) {
toastMessage?.let { msg ->
android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show()
onClearToast()
}
}
}
@@ -0,0 +1,28 @@
package com.m57.hermescontrol.ui.common
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.remote.NetworkResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
interface ToastHost {
fun clearToast()
}
inline fun <T> ViewModel.safeLaunchLoad(
crossinline apiCall: suspend () -> NetworkResult<T>,
crossinline onStart: () -> Unit,
crossinline onSuccess: (T) -> Unit,
crossinline onError: (String) -> Unit,
) {
onStart()
viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() }
when (result) {
is NetworkResult.Success -> onSuccess(result.data)
is NetworkResult.Failure -> onError(result.error.message ?: "Unknown error")
}
}
}
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.config package com.m57.hermescontrol.ui.config
import android.widget.Toast
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -25,7 +24,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -35,6 +33,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -44,19 +43,13 @@ fun ConfigScreen(
viewModel: ConfigViewModel = viewModel { ConfigViewModel() }, viewModel: ConfigViewModel = viewModel { ConfigViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var editingText by remember(state.yamlText) { mutableStateOf(state.yamlText ?: "") } var editingText by remember(state.yamlText) { mutableStateOf(state.yamlText ?: "") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadRawConfig() viewModel.loadRawConfig()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.config_screen_title)) }, title = { Text(stringResource(R.string.config_screen_title)) },
@@ -6,6 +6,8 @@ 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.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.safeLaunchLoad
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
@@ -23,38 +25,32 @@ data class ConfigUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class ConfigViewModel : ViewModel() { class ConfigViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ConfigUiState()) private val _uiState = MutableStateFlow(ConfigUiState())
val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow() val uiState: StateFlow<ConfigUiState> = _uiState.asStateFlow()
fun loadRawConfig() { fun loadRawConfig() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getRawConfig() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getRawConfig() } _uiState.update {
it.copy(
isLoading = false,
path = data.path,
yamlText = data.yaml,
)
} }
when (result) { },
is NetworkResult.Success -> { onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
path = result.data.path, errorMessage = "Failed to load config: $errorMsg",
yamlText = result.data.yaml, )
)
}
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load config: ${result.error.message}",
)
}
}
}
}
} }
fun saveRawConfig(yamlText: String) { fun saveRawConfig(yamlText: String) {
@@ -87,7 +83,7 @@ class ConfigViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.cron package com.m57.hermescontrol.ui.cron
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -26,7 +25,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -39,6 +37,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
import com.m57.hermescontrol.util.CronExpressionFormatter import com.m57.hermescontrol.util.CronExpressionFormatter
@@ -50,19 +49,13 @@ fun CronJobsScreen(
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() }, viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadCronJobs() viewModel.loadCronJobs()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_cron)) }, title = { Text(stringResource(R.string.screen_cron)) },
@@ -6,6 +6,8 @@ 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.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.safeLaunchLoad
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
@@ -21,32 +23,26 @@ data class CronJobsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class CronJobsViewModel : ViewModel() { class CronJobsViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(CronJobsUiState()) private val _uiState = MutableStateFlow(CronJobsUiState())
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow() val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
fun loadCronJobs() { fun loadCronJobs() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getCronJobs() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getCronJobs() } _uiState.update { it.copy(isLoading = false, jobs = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
_uiState.update { it.copy(isLoading = false, jobs = result.data.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load cron jobs: ${result.error.message}",
)
}
}
}
}
} }
fun pauseCronJob(id: String) { fun pauseCronJob(id: String) {
@@ -135,7 +131,7 @@ class CronJobsViewModel : ViewModel() {
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) } _uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.gateway package com.m57.hermescontrol.ui.gateway
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -31,7 +30,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -41,6 +39,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -50,18 +49,12 @@ fun GatewayScreen(
viewModel: GatewayViewModel = viewModel { GatewayViewModel() }, viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadStatus() viewModel.loadStatus()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_gateway)) }, title = { Text(stringResource(R.string.screen_gateway)) },
@@ -6,6 +6,8 @@ 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.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.safeLaunchLoad
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
@@ -22,32 +24,26 @@ data class GatewayUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class GatewayViewModel : ViewModel() { class GatewayViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(GatewayUiState()) private val _uiState = MutableStateFlow(GatewayUiState())
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow() val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
fun loadStatus() { fun loadStatus() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getStatus() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getStatus() } _uiState.update { it.copy(isLoading = false, status = data) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load status: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
_uiState.update { it.copy(isLoading = false, status = result.data) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load status: ${result.error.message}",
)
}
}
}
}
} }
fun startGateway() { fun startGateway() {
@@ -92,7 +88,7 @@ class GatewayViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.kanban package com.m57.hermescontrol.ui.kanban
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -38,7 +37,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -50,6 +48,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
private const val DEFAULT_COLUMN = "todo" private const val DEFAULT_COLUMN = "todo"
@@ -61,7 +60,6 @@ fun KanbanScreen(
viewModel: KanbanViewModel = viewModel { KanbanViewModel() }, viewModel: KanbanViewModel = viewModel { KanbanViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -80,12 +78,7 @@ fun KanbanScreen(
viewModel.loadBoards() viewModel.loadBoards()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.kanban_board_title)) }, title = { Text(stringResource(R.string.kanban_board_title)) },
@@ -8,6 +8,8 @@ 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.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.safeLaunchLoad
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,38 +28,32 @@ data class KanbanUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class KanbanViewModel : ViewModel() { class KanbanViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState()) private val _uiState = MutableStateFlow(KanbanUiState())
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow() val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
fun loadBoards() { fun loadBoards() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getKanbanBoards() } val boards = data.boards.orEmpty()
_uiState.update { it.copy(isLoading = false, boards = boards) }
val currentSlug = data.current
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
if (currentBoard != null) {
selectBoard(currentBoard)
} }
when (result) { },
is NetworkResult.Success -> { onError = { errorMsg ->
val boards = result.data.boards.orEmpty() _uiState.update {
_uiState.update { it.copy(isLoading = false, boards = boards) } it.copy(
val currentSlug = result.data.current isLoading = false,
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull() errorMessage = "Failed to load Kanban boards: $errorMsg",
if (currentBoard != null) { )
selectBoard(currentBoard)
}
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
}
}
} }
fun selectBoard(board: KanbanBoard) { fun selectBoard(board: KanbanBoard) {
@@ -183,7 +179,7 @@ class KanbanViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.keys package com.m57.hermescontrol.ui.keys
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,7 +33,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -46,6 +44,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -57,7 +56,6 @@ fun KeysScreen(
viewModel: KeysViewModel = viewModel { KeysViewModel() }, viewModel: KeysViewModel = viewModel { KeysViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -73,12 +71,7 @@ fun KeysScreen(
viewModel.loadKeys() viewModel.loadKeys()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_keys)) }, title = { Text(stringResource(R.string.screen_keys)) },
@@ -8,6 +8,8 @@ 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.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.safeLaunchLoad
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
@@ -24,37 +26,31 @@ data class KeysUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class KeysViewModel : ViewModel() { class KeysViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(KeysUiState()) private val _uiState = MutableStateFlow(KeysUiState())
val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow() val uiState: StateFlow<KeysUiState> = _uiState.asStateFlow()
fun loadKeys(reveal: Boolean = false) { fun loadKeys(reveal: Boolean = false) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getEnvVars() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getEnvVars() } _uiState.update {
it.copy(
isLoading = false,
envVars = data.orEmpty(),
)
} }
when (result) { },
is NetworkResult.Success -> { onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
envVars = result.data.orEmpty(), errorMessage = "Failed to load keys: $errorMsg",
) )
}
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load keys: ${result.error.message}",
)
}
}
}
}
} }
fun revealKey(key: String) { fun revealKey(key: String) {
@@ -110,7 +106,7 @@ class KeysViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.logs package com.m57.hermescontrol.ui.logs
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -20,7 +19,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -34,6 +32,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
@Composable @Composable
fun LogsScreen( fun LogsScreen(
@@ -42,7 +41,6 @@ fun LogsScreen(
viewModel: LogsViewModel = viewModel { LogsViewModel() }, viewModel: LogsViewModel = viewModel { LogsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val listState = rememberLazyListState() val listState = rememberLazyListState()
@@ -59,12 +57,7 @@ fun LogsScreen(
viewModel.loadLogs() viewModel.loadLogs()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
// Auto scroll to bottom when new logs arrive // Auto scroll to bottom when new logs arrive
LaunchedEffect(filteredLogs.size) { LaunchedEffect(filteredLogs.size) {
@@ -1,17 +1,14 @@
package com.m57.hermescontrol.ui.logs package com.m57.hermescontrol.ui.logs
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
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 com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
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
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class LogsUiState( data class LogsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -20,37 +17,31 @@ data class LogsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class LogsViewModel : ViewModel() { class LogsViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(LogsUiState()) private val _uiState = MutableStateFlow(LogsUiState())
val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow() val uiState: StateFlow<LogsUiState> = _uiState.asStateFlow()
fun loadLogs() { fun loadLogs() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getLogs() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getLogs() } val body = data
val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
val body = result.data
val logsList = body.lines ?: body.logs ?: emptyList()
_uiState.update { it.copy(isLoading = false, logs = logsList) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load logs: ${result.error.message}",
)
}
}
}
}
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.mcp package com.m57.hermescontrol.ui.mcp
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -29,7 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -41,6 +39,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -52,7 +51,6 @@ fun McpServersScreen(
viewModel: McpServersViewModel = viewModel { McpServersViewModel() }, viewModel: McpServersViewModel = viewModel { McpServersViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -69,12 +67,7 @@ fun McpServersScreen(
viewModel.loadServers() viewModel.loadServers()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_mcp_servers)) }, title = { Text(stringResource(R.string.screen_mcp_servers)) },
@@ -7,6 +7,8 @@ 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.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.safeLaunchLoad
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
@@ -23,37 +25,31 @@ data class McpServersUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class McpServersViewModel : ViewModel() { class McpServersViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(McpServersUiState()) private val _uiState = MutableStateFlow(McpServersUiState())
val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow() val uiState: StateFlow<McpServersUiState> = _uiState.asStateFlow()
fun loadServers() { fun loadServers() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getMcpServers() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getMcpServers() } _uiState.update {
it.copy(
isLoading = false,
servers = data.servers.orEmpty(),
)
} }
when (result) { },
is NetworkResult.Success -> { onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
servers = result.data.servers.orEmpty(), errorMessage = "Failed to load MCP servers: $errorMsg",
) )
}
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load MCP servers: ${result.error.message}",
)
}
}
}
}
} }
fun toggleServer(server: McpServer) { fun toggleServer(server: McpServer) {
@@ -167,7 +163,7 @@ class McpServersViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.model package com.m57.hermescontrol.ui.model
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -34,7 +33,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -46,6 +44,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -55,7 +54,6 @@ fun ModelScreen(
viewModel: ModelViewModel = viewModel { ModelViewModel() }, viewModel: ModelViewModel = viewModel { ModelViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var expandedProviderSlug by remember { mutableStateOf<String?>(null) } var expandedProviderSlug by remember { mutableStateOf<String?>(null) }
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -74,12 +72,7 @@ fun ModelScreen(
viewModel.loadModelOptions() viewModel.loadModelOptions()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_models)) }, title = { Text(stringResource(R.string.screen_models)) },
@@ -8,6 +8,7 @@ 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.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
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
@@ -24,7 +25,7 @@ data class ModelUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class ModelViewModel : ViewModel() { class ModelViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ModelUiState()) private val _uiState = MutableStateFlow(ModelUiState())
val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow() val uiState: StateFlow<ModelUiState> = _uiState.asStateFlow()
@@ -130,7 +131,7 @@ class ModelViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.pairing package com.m57.hermescontrol.ui.pairing
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -27,7 +26,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -37,6 +35,7 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -46,18 +45,12 @@ fun PairingScreen(
viewModel: PairingViewModel = viewModel { PairingViewModel() }, viewModel: PairingViewModel = viewModel { PairingViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadPairing() viewModel.loadPairing()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.pairing_screen_title)) }, title = { Text(stringResource(R.string.pairing_screen_title)) },
@@ -8,6 +8,8 @@ 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.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.safeLaunchLoad
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
@@ -24,32 +26,26 @@ data class PairingUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class PairingViewModel : ViewModel() { class PairingViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(PairingUiState()) private val _uiState = MutableStateFlow(PairingUiState())
val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow() val uiState: StateFlow<PairingUiState> = _uiState.asStateFlow()
fun loadPairing() { fun loadPairing() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getPairing() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getPairing() } _uiState.update { it.copy(isLoading = false, pairing = data) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load pairing info: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
_uiState.update { it.copy(isLoading = false, pairing = result.data) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load pairing info: ${result.error.message}",
)
}
}
}
}
} }
fun approvePairing( fun approvePairing(
@@ -106,7 +102,7 @@ class PairingViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.plugins package com.m57.hermescontrol.ui.plugins
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -29,7 +28,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -40,6 +38,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -51,7 +50,6 @@ fun PluginsScreen(
viewModel: PluginsViewModel = viewModel { PluginsViewModel() }, viewModel: PluginsViewModel = viewModel { PluginsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -67,12 +65,7 @@ fun PluginsScreen(
viewModel.loadPlugins() viewModel.loadPlugins()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_plugins)) }, title = { Text(stringResource(R.string.screen_plugins)) },
@@ -6,6 +6,8 @@ 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.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.safeLaunchLoad
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
@@ -21,33 +23,27 @@ data class PluginsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class PluginsViewModel : ViewModel() { class PluginsViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(PluginsUiState()) private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow() val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
fun loadPlugins() { fun loadPlugins() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getPlugins() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getPlugins() } val plugins = data.plugins.orEmpty()
_uiState.update { it.copy(isLoading = false, plugins = plugins) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load plugins: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
val plugins = result.data.plugins.orEmpty()
_uiState.update { it.copy(isLoading = false, plugins = plugins) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load plugins: ${result.error.message}",
)
}
}
}
}
} }
fun togglePlugin(plugin: PluginInfo) { fun togglePlugin(plugin: PluginInfo) {
@@ -165,7 +161,7 @@ class PluginsViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.profiles package com.m57.hermescontrol.ui.profiles
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -37,7 +36,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -48,6 +46,7 @@ import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -57,7 +56,6 @@ fun ProfilesScreen(
viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() }, viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var soulEditProfileName by remember { mutableStateOf<String?>(null) } var soulEditProfileName by remember { mutableStateOf<String?>(null) }
var modelEditProfileName by remember { mutableStateOf<String?>(null) } var modelEditProfileName by remember { mutableStateOf<String?>(null) }
@@ -68,12 +66,7 @@ fun ProfilesScreen(
viewModel.loadProfiles() viewModel.loadProfiles()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_profiles)) }, title = { Text(stringResource(R.string.screen_profiles)) },
@@ -9,6 +9,7 @@ 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.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost
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,7 +28,7 @@ data class ProfilesUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class ProfilesViewModel : ViewModel() { class ProfilesViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ProfilesUiState()) private val _uiState = MutableStateFlow(ProfilesUiState())
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow() val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
@@ -189,7 +190,7 @@ class ProfilesViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
@@ -1,18 +1,14 @@
package com.m57.hermescontrol.ui.sessions package com.m57.hermescontrol.ui.sessions
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.SessionInfo import com.m57.hermescontrol.data.model.SessionInfo
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.remote.safeApiCall
import kotlinx.coroutines.Dispatchers import com.m57.hermescontrol.ui.common.safeLaunchLoad
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
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class SessionsUiState( data class SessionsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
@@ -25,31 +21,25 @@ class SessionsViewModel : ViewModel() {
val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()
fun loadSessions() { fun loadSessions() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getSessions() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getSessions() } _uiState.update {
it.copy(
isLoading = false,
sessions = data?.sessions.orEmpty(),
)
} }
when (result) { },
is NetworkResult.Success -> { onError = { errorMsg ->
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
sessions = result.data?.sessions.orEmpty(), errorMessage = "Failed to load sessions: $errorMsg",
) )
}
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load sessions: ${result.error.message}",
)
}
}
}
}
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.skills package com.m57.hermescontrol.ui.skills
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -34,7 +33,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -52,6 +50,7 @@ import com.m57.hermescontrol.ui.common.FilterChipRow
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -62,7 +61,6 @@ fun SkillsScreen(
viewModel: SkillsViewModel = viewModel { SkillsViewModel() }, viewModel: SkillsViewModel = viewModel { SkillsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val statuses = listOf("All Statuses", "Enabled", "Disabled") val statuses = listOf("All Statuses", "Enabled", "Disabled")
@@ -81,12 +79,7 @@ fun SkillsScreen(
viewModel.loadSkills() viewModel.loadSkills()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
val filtered = val filtered =
remember(state.skills, query, selectedStatus, selectedCategory) { remember(state.skills, query, selectedStatus, selectedCategory) {
@@ -7,6 +7,8 @@ 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.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.safeLaunchLoad
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,32 +29,26 @@ data class SkillsUiState(
val saveContentSuccess: Boolean = false, val saveContentSuccess: Boolean = false,
) )
class SkillsViewModel : ViewModel() { class SkillsViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(SkillsUiState()) private val _uiState = MutableStateFlow(SkillsUiState())
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
fun loadSkills() { fun loadSkills() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getSkills() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getSkills() } _uiState.update { it.copy(isLoading = false, skills = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
_uiState.update { it.copy(isLoading = false, skills = result.data.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load skills: ${result.error.message}",
)
}
}
}
}
} }
fun toggleSkill(skill: Skill) { fun toggleSkill(skill: Skill) {
@@ -191,7 +187,7 @@ class SkillsViewModel : ViewModel() {
_uiState.update { it.copy(saveContentSuccess = false) } _uiState.update { it.copy(saveContentSuccess = false) }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.system package com.m57.hermescontrol.ui.system
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
@@ -26,7 +25,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -42,6 +40,7 @@ import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatCard import com.m57.hermescontrol.ui.common.StatCard
import com.m57.hermescontrol.ui.common.StatusBadge import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable @Composable
@@ -51,7 +50,6 @@ fun SystemScreen(
viewModel: SystemViewModel = viewModel { SystemViewModel() }, viewModel: SystemViewModel = viewModel { SystemViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
@@ -59,12 +57,7 @@ fun SystemScreen(
viewModel.loadSystemData() viewModel.loadSystemData()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_system)) }, title = { Text(stringResource(R.string.screen_system)) },
@@ -9,6 +9,8 @@ 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.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.safeLaunchLoad
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,7 +27,7 @@ data class SystemUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class SystemViewModel : ViewModel() { class SystemViewModel : ViewModel(), ToastHost {
companion object { companion object {
private const val TAG = "SystemViewModel" private const val TAG = "SystemViewModel"
} }
@@ -34,34 +36,28 @@ class SystemViewModel : ViewModel() {
val uiState: StateFlow<SystemUiState> = _uiState.asStateFlow() val uiState: StateFlow<SystemUiState> = _uiState.asStateFlow()
fun loadSystemData() { fun loadSystemData() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getSystemStats() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getSystemStats() } _uiState.update {
it.copy(
isLoading = false,
stats = data,
)
} }
when (result) { // Doctor is optional; many servers do not expose it.
is NetworkResult.Success -> { loadDoctorReport()
_uiState.update { },
it.copy( onError = { errorMsg ->
isLoading = false, _uiState.update {
stats = result.data, it.copy(
) isLoading = false,
} errorMessage = "Failed to load system stats: $errorMsg",
// Doctor is optional; many servers do not expose it. )
loadDoctorReport()
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load system stats: ${result.error.message}",
)
}
}
}
}
} }
private fun loadDoctorReport() { private fun loadDoctorReport() {
@@ -96,7 +92,7 @@ class SystemViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.toolsets package com.m57.hermescontrol.ui.toolsets
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -33,7 +32,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -45,6 +43,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -56,7 +55,6 @@ fun ToolsetsScreen(
viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() }, viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -73,12 +71,7 @@ fun ToolsetsScreen(
viewModel.loadToolsets() viewModel.loadToolsets()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_toolsets)) }, title = { Text(stringResource(R.string.screen_toolsets)) },
@@ -7,6 +7,8 @@ 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.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.safeLaunchLoad
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
@@ -22,32 +24,26 @@ data class ToolsetsUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class ToolsetsViewModel : ViewModel() { class ToolsetsViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(ToolsetsUiState()) private val _uiState = MutableStateFlow(ToolsetsUiState())
val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow() val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow()
fun loadToolsets() { fun loadToolsets() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getToolsets() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getToolsets() } _uiState.update { it.copy(isLoading = false, toolsets = data.orEmpty()) }
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load toolsets: $errorMsg",
)
} }
when (result) { },
is NetworkResult.Success -> { )
_uiState.update { it.copy(isLoading = false, toolsets = result.data.orEmpty()) }
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load toolsets: ${result.error.message}",
)
}
}
}
}
} }
fun toggleToolset(toolset: Toolset) { fun toggleToolset(toolset: Toolset) {
@@ -91,7 +87,7 @@ class ToolsetsViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }
@@ -1,6 +1,5 @@
package com.m57.hermescontrol.ui.webhooks package com.m57.hermescontrol.ui.webhooks
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -30,7 +29,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -42,6 +40,7 @@ import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing import com.m57.hermescontrol.ui.common.listItemSpacing
@@ -53,7 +52,6 @@ fun WebhooksScreen(
viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() }, viewModel: WebhooksViewModel = viewModel { WebhooksViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -69,12 +67,7 @@ fun WebhooksScreen(
viewModel.loadWebhooks() viewModel.loadWebhooks()
} }
LaunchedEffect(state.toastMessage) { ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
state.toastMessage?.let { msg ->
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
viewModel.clearToast()
}
}
HermesScaffold( HermesScaffold(
title = { Text(stringResource(R.string.screen_webhooks)) }, title = { Text(stringResource(R.string.screen_webhooks)) },
@@ -7,6 +7,8 @@ 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.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.safeLaunchLoad
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,40 +27,34 @@ data class WebhooksUiState(
val toastMessage: String? = null, val toastMessage: String? = null,
) )
class WebhooksViewModel : ViewModel() { class WebhooksViewModel : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(WebhooksUiState()) private val _uiState = MutableStateFlow(WebhooksUiState())
val uiState: StateFlow<WebhooksUiState> = _uiState.asStateFlow() val uiState: StateFlow<WebhooksUiState> = _uiState.asStateFlow()
fun loadWebhooks() { fun loadWebhooks() {
_uiState.update { it.copy(isLoading = true, errorMessage = null) } safeLaunchLoad(
viewModelScope.launch { apiCall = { safeApiCall { ApiClient.hermesApi.getWebhooks() } },
val result = onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
withContext(Dispatchers.IO) { onSuccess = { data ->
safeApiCall { ApiClient.hermesApi.getWebhooks() } val body = data
_uiState.update {
it.copy(
isLoading = false,
enabled = body.enabled,
baseUrl = body.base_url,
subscriptions = body.subscriptions.orEmpty(),
)
} }
when (result) { },
is NetworkResult.Success -> { onError = { errorMsg ->
val body = result.data _uiState.update {
_uiState.update { it.copy(
it.copy( isLoading = false,
isLoading = false, errorMessage = "Failed to load webhooks: $errorMsg",
enabled = body.enabled, )
baseUrl = body.base_url,
subscriptions = body.subscriptions.orEmpty(),
)
}
} }
},
is NetworkResult.Failure -> { )
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load webhooks: ${result.error.message}",
)
}
}
}
}
} }
fun toggleWebhooks(targetEnabled: Boolean) { fun toggleWebhooks(targetEnabled: Boolean) {
@@ -92,7 +88,7 @@ class WebhooksViewModel : ViewModel() {
} }
} }
fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
} }