Initial commit of HermesControl Android app
This commit is contained in:
commit
d6ff3de594
92 files changed
+10004
No files matched your search
@@ -0,0 +1,32 @@
|
||||
package com.m57.hermescontrol
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
AuthManager.init(this)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
HermesControlTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
) {
|
||||
MainNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package com.m57.hermescontrol
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberDrawerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.entryProvider
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.launch
|
||||
import com.m57.hermescontrol.ui.achievements.AchievementsScreen as AchievementsScreenContent
|
||||
import com.m57.hermescontrol.ui.chat.ChatScreen as ChatScreenContent
|
||||
import com.m57.hermescontrol.ui.connect.ConnectScreen as ConnectScreenContent
|
||||
import com.m57.hermescontrol.ui.cron.CronJobsScreen as CronJobsScreenContent
|
||||
import com.m57.hermescontrol.ui.gateway.GatewayScreen as GatewayScreenContent
|
||||
import com.m57.hermescontrol.ui.profiles.ProfilesScreen as ProfilesScreenContent
|
||||
import com.m57.hermescontrol.ui.settings.SettingsScreen as SettingsScreenContent
|
||||
import com.m57.hermescontrol.ui.skills.SkillsScreen as SkillsScreenContent
|
||||
import com.m57.hermescontrol.ui.toolsets.ToolsetsScreen as ToolsetsScreenContent
|
||||
|
||||
@Composable
|
||||
fun MainNavigation() {
|
||||
val hasToken = !AuthManager.getToken().isNullOrBlank()
|
||||
val startScreen: NavKey = if (hasToken) ChatScreen else ConnectScreen
|
||||
|
||||
val backStack = rememberNavBackStack(startScreen)
|
||||
NavigationController.backStack = backStack
|
||||
|
||||
val currentScreen = backStack.lastOrNull() ?: startScreen
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val gesturesEnabled =
|
||||
currentScreen == ChatScreen || currentScreen == SkillsScreen || currentScreen == CronJobsScreen ||
|
||||
currentScreen == GatewayScreen || currentScreen == ProfilesScreen ||
|
||||
currentScreen == ToolsetsScreen || currentScreen == AchievementsScreen
|
||||
val openDrawer = { scope.launch { drawerState.open() } }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = gesturesEnabled,
|
||||
drawerContent = {
|
||||
if (gesturesEnabled) {
|
||||
ModalDrawerSheet {
|
||||
Text(
|
||||
text = "Hermes Control",
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
HorizontalDivider()
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Chat") },
|
||||
selected = currentScreen == ChatScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(ChatScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Skills") },
|
||||
selected = currentScreen == SkillsScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(SkillsScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Cron Jobs") },
|
||||
selected = currentScreen == CronJobsScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(CronJobsScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Profiles") },
|
||||
selected = currentScreen == ProfilesScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(ProfilesScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Toolsets") },
|
||||
selected = currentScreen == ToolsetsScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(ToolsetsScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Achievements") },
|
||||
selected = currentScreen == AchievementsScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(AchievementsScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
label = { Text("Gateway") },
|
||||
selected = currentScreen == GatewayScreen,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
NavigationController.navigateTo(GatewayScreen)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider =
|
||||
entryProvider {
|
||||
entry<ConnectScreen> {
|
||||
ConnectScreenContent(
|
||||
onConnected = {
|
||||
backStack.clear()
|
||||
backStack.add(ChatScreen)
|
||||
},
|
||||
modifier = Modifier.safeDrawingPadding(),
|
||||
)
|
||||
}
|
||||
|
||||
entry<ChatScreen> {
|
||||
ChatScreenContent(
|
||||
onNavigateToSettings = {
|
||||
backStack.add(SettingsScreen)
|
||||
},
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<SettingsScreen> {
|
||||
SettingsScreenContent(
|
||||
onBack = {
|
||||
backStack.removeLastOrNull()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
entry<SkillsScreen> {
|
||||
SkillsScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<CronJobsScreen> {
|
||||
CronJobsScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ProfilesScreen> {
|
||||
ProfilesScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<ToolsetsScreen> {
|
||||
ToolsetsScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<AchievementsScreen> {
|
||||
AchievementsScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
|
||||
entry<GatewayScreen> {
|
||||
GatewayScreenContent(
|
||||
onOpenDrawer = { openDrawer() },
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
object NavigationController {
|
||||
var backStack: NavBackStack<NavKey>? = null
|
||||
|
||||
fun navigateTo(key: NavKey) {
|
||||
val stack = backStack ?: return
|
||||
if (stack.lastOrNull() == key) return
|
||||
|
||||
if (key == ChatScreen || key == SkillsScreen || key == CronJobsScreen) {
|
||||
stack.clear()
|
||||
}
|
||||
stack.add(key)
|
||||
}
|
||||
|
||||
fun add(key: NavKey) {
|
||||
backStack?.add(key)
|
||||
}
|
||||
|
||||
fun goBack() {
|
||||
backStack?.removeLastOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.m57.hermescontrol
|
||||
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable data object ConnectScreen : NavKey
|
||||
|
||||
@Serializable data object ChatScreen : NavKey
|
||||
|
||||
@Serializable data object SettingsScreen : NavKey
|
||||
|
||||
@Serializable data object SkillsScreen : NavKey
|
||||
|
||||
@Serializable data object CronJobsScreen : NavKey
|
||||
|
||||
@Serializable data object GatewayScreen : NavKey
|
||||
|
||||
@Serializable data object ProfilesScreen : NavKey
|
||||
|
||||
@Serializable data object ToolsetsScreen : NavKey
|
||||
|
||||
@Serializable data object AchievementsScreen : NavKey
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
/**
|
||||
* Singleton that manages encrypted storage of the Hermes dashboard token
|
||||
* and connection settings.
|
||||
*
|
||||
* Must call [init] with a Context before any other method.
|
||||
*/
|
||||
object AuthManager {
|
||||
private const val PREFS_FILE = "hermes_secure_prefs"
|
||||
|
||||
private const val KEY_TOKEN = "auth_token"
|
||||
private const val KEY_HOST = "host"
|
||||
private const val KEY_PORT = "port"
|
||||
private const val KEY_AUTO_RECONNECT = "auto_reconnect"
|
||||
|
||||
private const val DEFAULT_HOST = "127.0.0.1"
|
||||
private const val DEFAULT_PORT = 9119
|
||||
private const val DEFAULT_AUTO_RECONNECT = true
|
||||
|
||||
@Volatile
|
||||
private var prefs: SharedPreferences? = null
|
||||
|
||||
/**
|
||||
* Initialise the encrypted preferences.
|
||||
* Call this once from Application.onCreate() or MainActivity.onCreate().
|
||||
*/
|
||||
fun init(context: Context) {
|
||||
if (prefs != null) return
|
||||
synchronized(this) {
|
||||
if (prefs != null) return
|
||||
val masterKey =
|
||||
MasterKey
|
||||
.Builder(context.applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
prefs =
|
||||
EncryptedSharedPreferences.create(
|
||||
context.applicationContext,
|
||||
PREFS_FILE,
|
||||
masterKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requirePrefs(): SharedPreferences =
|
||||
prefs ?: throw IllegalStateException(
|
||||
"AuthManager not initialised – call AuthManager.init(context) first",
|
||||
)
|
||||
|
||||
// ── Token ────────────────────────────────────────────────────────────
|
||||
|
||||
fun getToken(): String? = requirePrefs().getString(KEY_TOKEN, null)
|
||||
|
||||
fun setToken(token: String?) {
|
||||
requirePrefs().edit().putString(KEY_TOKEN, token).apply()
|
||||
}
|
||||
|
||||
// ── Host ─────────────────────────────────────────────────────────────
|
||||
|
||||
fun getHost(): String = requirePrefs().getString(KEY_HOST, DEFAULT_HOST) ?: DEFAULT_HOST
|
||||
|
||||
fun setHost(host: String) {
|
||||
requirePrefs().edit().putString(KEY_HOST, host).apply()
|
||||
}
|
||||
|
||||
// ── Port ─────────────────────────────────────────────────────────────
|
||||
|
||||
fun getPort(): Int = requirePrefs().getInt(KEY_PORT, DEFAULT_PORT)
|
||||
|
||||
fun setPort(port: Int) {
|
||||
requirePrefs().edit().putInt(KEY_PORT, port).apply()
|
||||
}
|
||||
|
||||
// ── Auto-reconnect ───────────────────────────────────────────────────
|
||||
|
||||
fun isAutoReconnect(): Boolean = requirePrefs().getBoolean(KEY_AUTO_RECONNECT, DEFAULT_AUTO_RECONNECT)
|
||||
|
||||
fun setAutoReconnect(enabled: Boolean) {
|
||||
requirePrefs().edit().putBoolean(KEY_AUTO_RECONNECT, enabled).apply()
|
||||
}
|
||||
|
||||
/** Convenience: build the base URL from current host + port. */
|
||||
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
|
||||
|
||||
/** Convenience: build the WebSocket URL with token query param. */
|
||||
fun wsUrl(): String = "ws://${getHost()}:${getPort()}/api/ws?token=${getToken().orEmpty()}"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class AchievementsResponse(
|
||||
val achievements: List<Achievement>,
|
||||
)
|
||||
|
||||
data class Achievement(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val category: String?,
|
||||
val kind: String?,
|
||||
val icon: String?,
|
||||
val unlocked: Boolean,
|
||||
val discovered: Boolean,
|
||||
val state: String?,
|
||||
val tier: String?,
|
||||
val progress: Double?,
|
||||
val next_tier: String?,
|
||||
val next_threshold: Double?,
|
||||
val progress_pct: Double?,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class CronJob(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val schedule: String?,
|
||||
val state: String?,
|
||||
val last_run_status: String?,
|
||||
val next_run: String?,
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class ProfilesResponse(
|
||||
val profiles: List<ProfileInfo>,
|
||||
)
|
||||
|
||||
data class ProfileInfo(
|
||||
val name: String,
|
||||
val path: String?,
|
||||
val is_default: Boolean?,
|
||||
val model: String?,
|
||||
val provider: String?,
|
||||
val has_env: Boolean?,
|
||||
val skill_count: Int?,
|
||||
val gateway_running: Boolean?,
|
||||
val description: String?,
|
||||
)
|
||||
|
||||
data class ActiveProfileResponse(
|
||||
val active: String,
|
||||
val current: String?,
|
||||
)
|
||||
|
||||
data class SetActiveProfileRequest(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class ProfileSoulResponse(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
data class UpdateProfileSoulRequest(
|
||||
val content: String,
|
||||
)
|
||||
|
||||
data class UpdateProfileModelRequest(
|
||||
val provider: String,
|
||||
val model: String,
|
||||
)
|
||||
|
||||
data class UpdateProfileDescriptionRequest(
|
||||
val description: String,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class SessionListResponse(
|
||||
val sessions: List<SessionInfo>,
|
||||
)
|
||||
|
||||
data class SessionInfo(
|
||||
val id: String,
|
||||
val title: String?,
|
||||
val created_at: String?,
|
||||
val message_count: Int?,
|
||||
val status: String?,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class SessionMessagesResponse(
|
||||
val messages: List<SessionMessage>,
|
||||
)
|
||||
|
||||
data class SessionMessage(
|
||||
val role: String?,
|
||||
val content: String?,
|
||||
val timestamp: String?,
|
||||
val type: String?,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class Skill(
|
||||
val name: String,
|
||||
val description: String?,
|
||||
val category: String?,
|
||||
val enabled: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class StatusResponse(
|
||||
val version: String?,
|
||||
val gateway_running: Boolean?,
|
||||
val active_sessions: Int?,
|
||||
val auth_required: Boolean?,
|
||||
val gateway_platforms: Map<String, PlatformStatus>?,
|
||||
)
|
||||
|
||||
data class PlatformStatus(
|
||||
val state: String?,
|
||||
val error_code: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
/**
|
||||
* Wrapper around the /api/system/stats response.
|
||||
*
|
||||
* The payload shape can change across Hermes versions, so we keep it
|
||||
* flexible with a Map and provide convenience accessors for the
|
||||
* most commonly used fields.
|
||||
*/
|
||||
data class SystemStatsResponse(
|
||||
val cpu: Map<String, Any?>? = null,
|
||||
val memory: Map<String, Any?>? = null,
|
||||
val disk: Map<String, Any?>? = null,
|
||||
val uptime: Any? = null,
|
||||
val extra: Map<String, Any?>? = null,
|
||||
) {
|
||||
/** CPU usage percentage, if available. */
|
||||
val cpuPercent: Double?
|
||||
get() = (cpu?.get("percent") as? Number)?.toDouble()
|
||||
|
||||
/** Memory usage percentage, if available. */
|
||||
val memoryPercent: Double?
|
||||
get() = (memory?.get("percent") as? Number)?.toDouble()
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class ToggleSkillRequest(
|
||||
val name: String,
|
||||
val enabled: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
data class Toolset(
|
||||
val name: String,
|
||||
val label: String?,
|
||||
val description: String?,
|
||||
val enabled: Boolean,
|
||||
val available: Boolean?,
|
||||
val configured: Boolean?,
|
||||
val tools: List<String>?,
|
||||
)
|
||||
|
||||
data class ToolsetToggleRequest(
|
||||
val enabled: Boolean,
|
||||
val profile: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Provides a Retrofit-backed [HermesApiService].
|
||||
*
|
||||
* The client is lazily built using the current [AuthManager] settings and
|
||||
* can be rebuilt at any time via [rebuild] (e.g. after the user changes
|
||||
* host / port / token).
|
||||
*/
|
||||
object ApiClient {
|
||||
@Volatile
|
||||
private var retrofit: Retrofit? = null
|
||||
|
||||
@Volatile
|
||||
private var service: HermesApiService? = null
|
||||
|
||||
/** The current [HermesApiService] instance. Lazily created on first access. */
|
||||
val hermesApi: HermesApiService
|
||||
get() {
|
||||
return service ?: synchronized(this) {
|
||||
service ?: buildService().also { service = it }
|
||||
}
|
||||
}
|
||||
|
||||
/** Force-rebuild the Retrofit client (e.g. after settings change). */
|
||||
fun rebuild() {
|
||||
synchronized(this) {
|
||||
retrofit = null
|
||||
service = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun buildService(): HermesApiService {
|
||||
val logging =
|
||||
HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
|
||||
val authInterceptor =
|
||||
Interceptor { chain ->
|
||||
val token = AuthManager.getToken()
|
||||
val request =
|
||||
if (!token.isNullOrBlank()) {
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
chain.proceed(request)
|
||||
}
|
||||
|
||||
val okHttp =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(15, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val rf =
|
||||
Retrofit
|
||||
.Builder()
|
||||
.baseUrl(AuthManager.baseUrl())
|
||||
.client(okHttp)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.also { retrofit = it }
|
||||
|
||||
return rf.create(HermesApiService::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.model.AchievementsResponse
|
||||
import com.m57.hermescontrol.data.model.ActiveProfileResponse
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.model.ProfileSoulResponse
|
||||
import com.m57.hermescontrol.data.model.ProfilesResponse
|
||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||
import com.m57.hermescontrol.data.model.SessionMessagesResponse
|
||||
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import com.m57.hermescontrol.data.model.StatusResponse
|
||||
import com.m57.hermescontrol.data.model.SystemStatsResponse
|
||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||
import retrofit2.Response
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface HermesApiService {
|
||||
@GET("api/status")
|
||||
suspend fun getStatus(): Response<StatusResponse>
|
||||
|
||||
@GET("api/sessions")
|
||||
suspend fun getSessions(): Response<SessionListResponse>
|
||||
|
||||
@GET("api/sessions/{id}/messages")
|
||||
suspend fun getSessionMessages(
|
||||
@Path("id") sessionId: String,
|
||||
): Response<SessionMessagesResponse>
|
||||
|
||||
@GET("api/system/stats")
|
||||
suspend fun getSystemStats(): Response<SystemStatsResponse>
|
||||
|
||||
@GET("api/config")
|
||||
suspend fun getConfig(): Response<Map<String, Any?>>
|
||||
|
||||
@GET("api/skills")
|
||||
suspend fun getSkills(): Response<List<Skill>>
|
||||
|
||||
@PUT("api/skills/toggle")
|
||||
suspend fun toggleSkill(
|
||||
@Body body: ToggleSkillRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/cron/jobs")
|
||||
suspend fun getCronJobs(): Response<List<CronJob>>
|
||||
|
||||
@POST("api/cron/jobs/{id}/pause")
|
||||
suspend fun pauseCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/cron/jobs/{id}/resume")
|
||||
suspend fun resumeCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/cron/jobs/{id}/trigger")
|
||||
suspend fun triggerCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@DELETE("api/cron/jobs/{id}")
|
||||
suspend fun deleteCronJob(
|
||||
@Path("id") id: String,
|
||||
): Response<Unit>
|
||||
|
||||
@POST("api/gateway/start")
|
||||
suspend fun startGateway(): Response<Unit>
|
||||
|
||||
@POST("api/gateway/stop")
|
||||
suspend fun stopGateway(): Response<Unit>
|
||||
|
||||
@POST("api/gateway/restart")
|
||||
suspend fun restartGateway(): Response<Unit>
|
||||
|
||||
@GET("api/profiles")
|
||||
suspend fun getProfiles(): Response<ProfilesResponse>
|
||||
|
||||
@GET("api/profiles/active")
|
||||
suspend fun getActiveProfile(): Response<ActiveProfileResponse>
|
||||
|
||||
@POST("api/profiles/active")
|
||||
suspend fun setActiveProfile(
|
||||
@Body body: SetActiveProfileRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/profiles/{name}/soul")
|
||||
suspend fun getProfileSoul(
|
||||
@Path("name") name: String,
|
||||
): Response<ProfileSoulResponse>
|
||||
|
||||
@PUT("api/profiles/{name}/soul")
|
||||
suspend fun updateProfileSoul(
|
||||
@Path("name") name: String,
|
||||
@Body body: UpdateProfileSoulRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("api/profiles/{name}/model")
|
||||
suspend fun updateProfileModel(
|
||||
@Path("name") name: String,
|
||||
@Body body: UpdateProfileModelRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@PUT("api/profiles/{name}/description")
|
||||
suspend fun updateProfileDescription(
|
||||
@Path("name") name: String,
|
||||
@Body body: UpdateProfileDescriptionRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/tools/toolsets")
|
||||
suspend fun getToolsets(): Response<List<Toolset>>
|
||||
|
||||
@PUT("api/tools/toolsets/{name}")
|
||||
suspend fun toggleToolset(
|
||||
@Path("name") name: String,
|
||||
@Body body: ToolsetToggleRequest,
|
||||
): Response<Unit>
|
||||
|
||||
@GET("api/plugins/hermes-achievements/achievements")
|
||||
suspend fun getAchievements(): Response<AchievementsResponse>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Converts raw [JsonRpcResponse] objects into typed [WsEvent] instances.
|
||||
*
|
||||
* The Hermes TUI gateway sends events as JSON-RPC **notifications** (no `id`
|
||||
* field). The `method` is always `"event"` and the event type lives in
|
||||
* `params.type`. The event payload is in `params.payload`.
|
||||
*
|
||||
* Regular RPC responses have an `id` and either a `result` or `error`.
|
||||
*/
|
||||
object EventParser {
|
||||
private const val TAG = "EventParser"
|
||||
|
||||
fun parse(
|
||||
response: JsonRpcResponse,
|
||||
rawJson: String = "",
|
||||
): WsEvent {
|
||||
// ── RPC response (has id) ────────────────────────────────────────
|
||||
val id = response.id
|
||||
if (id != null) {
|
||||
return if (response.error != null) {
|
||||
WsEvent.RpcError(id, response.error)
|
||||
} else {
|
||||
WsEvent.RpcResult(id, response.result)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification / event (no id, has method) ─────────────────────
|
||||
val params = response.params ?: return WsEvent.Unknown(rawJson)
|
||||
val eventType = params["type"] as? String ?: return WsEvent.Unknown(rawJson)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val payload = params["payload"] as? Map<String, Any?>
|
||||
|
||||
val sessionId = payload?.get("session_id") as? String
|
||||
|
||||
return when (eventType) {
|
||||
"gateway.ready" -> {
|
||||
WsEvent.GatewayReady(payload)
|
||||
}
|
||||
|
||||
"session.info" -> {
|
||||
WsEvent.SessionInfo(payload)
|
||||
}
|
||||
|
||||
"message.start" -> {
|
||||
WsEvent.MessageStart(sessionId)
|
||||
}
|
||||
|
||||
"message.token" -> {
|
||||
val token = payload?.get("text") as? String ?: ""
|
||||
WsEvent.MessageToken(token, sessionId)
|
||||
}
|
||||
|
||||
"thinking.delta" -> {
|
||||
val token = payload?.get("text") as? String ?: ""
|
||||
WsEvent.ThinkingDelta(token, sessionId)
|
||||
}
|
||||
|
||||
"message.complete" -> {
|
||||
val text = payload?.get("text") as? String ?: ""
|
||||
WsEvent.MessageComplete(text, sessionId)
|
||||
}
|
||||
|
||||
"message.done" -> {
|
||||
WsEvent.MessageDone(sessionId)
|
||||
}
|
||||
|
||||
"tool.start" -> {
|
||||
val name = payload?.get("name") as? String
|
||||
WsEvent.ToolStart(name, payload)
|
||||
}
|
||||
|
||||
"tool.complete" -> {
|
||||
val name = payload?.get("name") as? String
|
||||
WsEvent.ToolComplete(name, payload)
|
||||
}
|
||||
|
||||
"clarify.request" -> {
|
||||
val text = payload?.get("text") as? String
|
||||
val rawOptions = payload?.get("options")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val options = (rawOptions as? List<*>)?.filterIsInstance<String>()
|
||||
WsEvent.ClarifyRequest(text, options)
|
||||
}
|
||||
|
||||
"status.update" -> {
|
||||
val status = payload?.get("status") as? String
|
||||
WsEvent.StatusUpdate(status, payload)
|
||||
}
|
||||
|
||||
"session.updated" -> {
|
||||
WsEvent.SessionUpdated(payload)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.w(TAG, "Unknown event type: $eventType")
|
||||
WsEvent.Unknown(rawJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.google.gson.Gson
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
/**
|
||||
* WebSocket client for the Hermes Dashboard JSON-RPC 2.0 interface.
|
||||
*
|
||||
* Connects to `ws://HOST:PORT/api/ws?token=TOKEN`, auto-reconnects with
|
||||
* exponential backoff, and emits parsed [WsEvent]s via [events] SharedFlow
|
||||
* as well as direct callbacks.
|
||||
*/
|
||||
object HermesWsClient {
|
||||
private const val TAG = "HermesWsClient"
|
||||
|
||||
// ── Backoff settings ─────────────────────────────────────────────────
|
||||
|
||||
private const val INITIAL_BACKOFF_MS = 1_000L
|
||||
private const val MAX_BACKOFF_MS = 30_000L
|
||||
private const val BACKOFF_MULTIPLIER = 2.0
|
||||
|
||||
// ── Internal state (all access through synchronized / atomic) ────────
|
||||
|
||||
private val gson = Gson()
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val connected = AtomicBoolean(false)
|
||||
private val intentionalClose = AtomicBoolean(false)
|
||||
|
||||
@Volatile
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
@Volatile
|
||||
private var currentBackoff = INITIAL_BACKOFF_MS
|
||||
|
||||
private val okHttpClient =
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.readTimeout(0, TimeUnit.MILLISECONDS) // keep-alive forever
|
||||
.pingInterval(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
// ── Public observable stream ─────────────────────────────────────────
|
||||
|
||||
private val _events = MutableSharedFlow<WsEvent>(extraBufferCapacity = 64)
|
||||
|
||||
/** Collect this from ViewModels to receive all parsed [WsEvent]s. */
|
||||
val events: SharedFlow<WsEvent> = _events.asSharedFlow()
|
||||
|
||||
// ── Callbacks (legacy / convenience) ─────────────────────────────────
|
||||
|
||||
var onMessage: ((WsEvent) -> Unit)? = null
|
||||
var onConnected: (() -> Unit)? = null
|
||||
var onDisconnected: ((reason: String?) -> Unit)? = null
|
||||
var onError: ((Throwable) -> Unit)? = null
|
||||
|
||||
// ── Connection helpers ────────────────────────────────────────────────
|
||||
|
||||
val isConnected: Boolean get() = connected.get()
|
||||
|
||||
/** Open a WebSocket connection using settings from [AuthManager]. */
|
||||
fun connect() {
|
||||
if (connected.get()) {
|
||||
Log.d(TAG, "Already connected — skipping")
|
||||
return
|
||||
}
|
||||
intentionalClose.set(false)
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
openSocket()
|
||||
}
|
||||
|
||||
/** Cleanly close the WebSocket and stop auto-reconnect. */
|
||||
fun disconnect() {
|
||||
intentionalClose.set(true)
|
||||
webSocket?.close(1000, "Client closed")
|
||||
webSocket = null
|
||||
connected.set(false)
|
||||
}
|
||||
|
||||
// ── Send helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a JSON-RPC request with the given [method] and optional [params].
|
||||
* @return the request id used (can be matched against [WsEvent.RpcResult]).
|
||||
*/
|
||||
fun send(
|
||||
method: String,
|
||||
params: Map<String, Any> = emptyMap(),
|
||||
): String {
|
||||
val id = requestId.incrementAndGet().toString()
|
||||
val request = JsonRpcRequest(id = id, method = method, params = params)
|
||||
val json = gson.toJson(request)
|
||||
Log.d(TAG, "→ $json")
|
||||
webSocket?.send(json) ?: Log.w(TAG, "send() called while disconnected")
|
||||
return id
|
||||
}
|
||||
|
||||
/** Convenience: submit a user prompt to an existing session. */
|
||||
fun sendMessage(
|
||||
sessionId: String,
|
||||
text: String,
|
||||
): String =
|
||||
send(
|
||||
method = WsMethods.PROMPT_SUBMIT,
|
||||
params = mapOf("session_id" to sessionId, "text" to text),
|
||||
)
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun openSocket() {
|
||||
val url = AuthManager.wsUrl()
|
||||
Log.d(TAG, "Connecting to $url")
|
||||
|
||||
val request = Request.Builder().url(url).build()
|
||||
webSocket = okHttpClient.newWebSocket(request, WsListenerImpl())
|
||||
}
|
||||
|
||||
private fun scheduleReconnect() {
|
||||
if (intentionalClose.get()) return
|
||||
if (!AuthManager.isAutoReconnect()) {
|
||||
Log.d(TAG, "Auto-reconnect disabled")
|
||||
return
|
||||
}
|
||||
val delay = currentBackoff
|
||||
currentBackoff =
|
||||
(currentBackoff * BACKOFF_MULTIPLIER)
|
||||
.toLong()
|
||||
.coerceAtMost(MAX_BACKOFF_MS)
|
||||
Log.d(TAG, "Reconnecting in ${delay}ms …")
|
||||
|
||||
// Simple thread-based scheduling; could be replaced with coroutine delay.
|
||||
Thread {
|
||||
try {
|
||||
Thread.sleep(delay)
|
||||
} catch (_: InterruptedException) {
|
||||
return@Thread
|
||||
}
|
||||
if (!intentionalClose.get() && !connected.get()) {
|
||||
openSocket()
|
||||
}
|
||||
}.apply {
|
||||
isDaemon = true
|
||||
name = "hermes-ws-reconnect"
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(event: WsEvent) {
|
||||
_events.tryEmit(event)
|
||||
onMessage?.invoke(event)
|
||||
}
|
||||
|
||||
// ── Listener ─────────────────────────────────────────────────────────
|
||||
|
||||
private class WsListenerImpl : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
ws: WebSocket,
|
||||
response: Response,
|
||||
) {
|
||||
Log.i(TAG, "WebSocket opened")
|
||||
connected.set(true)
|
||||
currentBackoff = INITIAL_BACKOFF_MS
|
||||
onConnected?.invoke()
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
ws: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
Log.d(TAG, "← $text")
|
||||
try {
|
||||
val rpc = gson.fromJson(text, JsonRpcResponse::class.java)
|
||||
val event = EventParser.parse(rpc, text)
|
||||
emit(event)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to parse message", e)
|
||||
emit(WsEvent.Unknown(text))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(
|
||||
ws: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
Log.d(TAG, "WebSocket closing: $code $reason")
|
||||
ws.close(1000, null)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
ws: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
Log.i(TAG, "WebSocket closed: $code $reason")
|
||||
connected.set(false)
|
||||
onDisconnected?.invoke(reason)
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
ws: WebSocket,
|
||||
t: Throwable,
|
||||
response: Response?,
|
||||
) {
|
||||
Log.e(TAG, "WebSocket failure: ${t.message}", t)
|
||||
connected.set(false)
|
||||
onError?.invoke(t)
|
||||
onDisconnected?.invoke(t.message)
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
/**
|
||||
* Outgoing JSON-RPC 2.0 request sent over the WebSocket.
|
||||
*/
|
||||
data class JsonRpcRequest(
|
||||
val jsonrpc: String = "2.0",
|
||||
val id: String,
|
||||
val method: String,
|
||||
val params: Map<String, Any> = emptyMap(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Incoming JSON-RPC 2.0 response **or** server-pushed notification.
|
||||
*
|
||||
* - RPC result/error → [id] is non-null, [result] or [error] is populated.
|
||||
* - Event notification → [id] is null, [method] is `"event"`, event data in [params].
|
||||
*/
|
||||
data class JsonRpcResponse(
|
||||
val jsonrpc: String?,
|
||||
val id: String?,
|
||||
val result: Any?,
|
||||
val error: JsonRpcError?,
|
||||
val method: String?,
|
||||
val params: Map<String, Any?>?,
|
||||
)
|
||||
|
||||
data class JsonRpcError(
|
||||
val code: Int,
|
||||
val message: String,
|
||||
val data: Any? = null,
|
||||
)
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
/** Parsed WebSocket events emitted by [HermesWsClient]. */
|
||||
sealed class WsEvent {
|
||||
// ── Gateway lifecycle ────────────────────────────────────────────────
|
||||
|
||||
data class GatewayReady(
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Session information ──────────────────────────────────────────────
|
||||
|
||||
data class SessionInfo(
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Message streaming ────────────────────────────────────────────────
|
||||
|
||||
data class MessageStart(
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageToken(
|
||||
val token: String,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class ThinkingDelta(
|
||||
val token: String,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageComplete(
|
||||
val text: String,
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
data class MessageDone(
|
||||
val sessionId: String?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Tool execution ───────────────────────────────────────────────────
|
||||
|
||||
data class ToolStart(
|
||||
val name: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
data class ToolComplete(
|
||||
val name: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Interactive ──────────────────────────────────────────────────────
|
||||
|
||||
data class ClarifyRequest(
|
||||
val text: String?,
|
||||
val options: List<String>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Status ───────────────────────────────────────────────────────────
|
||||
|
||||
data class StatusUpdate(
|
||||
val status: String?,
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
data class SessionUpdated(
|
||||
val data: Map<String, Any?>?,
|
||||
) : WsEvent()
|
||||
|
||||
// ── RPC responses ────────────────────────────────────────────────────
|
||||
|
||||
data class RpcResult(
|
||||
val id: String,
|
||||
val result: Any?,
|
||||
) : WsEvent()
|
||||
|
||||
data class RpcError(
|
||||
val id: String,
|
||||
val error: JsonRpcError,
|
||||
) : WsEvent()
|
||||
|
||||
// ── Fallback ─────────────────────────────────────────────────────────
|
||||
|
||||
data class Unknown(
|
||||
val raw: String,
|
||||
) : WsEvent()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
/** JSON-RPC method name constants used with [HermesWsClient.send]. */
|
||||
object WsMethods {
|
||||
const val SESSION_LIST = "session.list"
|
||||
const val SESSION_ACTIVE_LIST = "session.active_list"
|
||||
const val SESSION_STATUS = "session.status"
|
||||
const val SESSION_HISTORY = "session.history"
|
||||
const val SESSION_RESUME = "session.resume"
|
||||
const val SESSION_CREATE = "session.create"
|
||||
const val PROMPT_SUBMIT = "prompt.submit"
|
||||
const val SESSION_INTERRUPT = "session.interrupt"
|
||||
const val SESSION_DELETE = "session.delete"
|
||||
const val SESSION_TITLE = "session.title"
|
||||
const val CLARIFY_RESPOND = "clarify.respond"
|
||||
const val APPROVAL_RESPOND = "approval.respond"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary - Deep Purple
|
||||
val HermesPurple = Color(0xFF6B4EFF)
|
||||
val HermesPurpleLight = Color(0xFF9D8AFF)
|
||||
val HermesPurpleDark = Color(0xFF4A35B3)
|
||||
val HermesPurple80 = Color(0xFFCFC3FF)
|
||||
|
||||
// Secondary - Amber / Gold
|
||||
val HermesAmber = Color(0xFFFFB300)
|
||||
val HermesAmberLight = Color(0xFFFFE54C)
|
||||
val HermesAmberDark = Color(0xFFC68400)
|
||||
val HermesAmber80 = Color(0xFFFFE082)
|
||||
|
||||
// Dark theme surfaces
|
||||
val DarkBackground = Color(0xFF121212)
|
||||
val DarkSurface = Color(0xFF1E1E2E)
|
||||
val DarkSurfaceVariant = Color(0xFF2A2A3C)
|
||||
val DarkOnSurface = Color(0xFFE6E1E5)
|
||||
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
|
||||
|
||||
// Light theme surfaces
|
||||
val LightBackground = Color(0xFFFFFBFE)
|
||||
val LightSurface = Color(0xFFFFFBFE)
|
||||
val LightSurfaceVariant = Color(0xFFE7E0EC)
|
||||
val LightOnSurface = Color(0xFF1C1B1F)
|
||||
val LightOnSurfaceVariant = Color(0xFF49454F)
|
||||
|
||||
// Status colors
|
||||
val StatusGreen = Color(0xFF4CAF50)
|
||||
val StatusRed = Color(0xFFEF5350)
|
||||
val StatusYellow = Color(0xFFFFC107)
|
||||
|
||||
// Chat bubble colors
|
||||
val UserBubble = Color(0xFF6B4EFF)
|
||||
val UserBubbleDark = Color(0xFF5A3FE0)
|
||||
val AssistantBubble = Color(0xFF2A2A3C)
|
||||
val AssistantBubbleLight = Color(0xFFE7E0EC)
|
||||
val SystemMessageColor = Color(0xFF9E9E9E)
|
||||
val ToolChipColor = Color(0xFF37474F)
|
||||
val ToolChipColorLight = Color(0xFFECEFF1)
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
enum class ThemePreference { SYSTEM, LIGHT, DARK }
|
||||
|
||||
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||
|
||||
private val HermesDarkColorScheme =
|
||||
darkColorScheme(
|
||||
primary = HermesPurple,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = HermesPurpleDark,
|
||||
onPrimaryContainer = HermesPurple80,
|
||||
secondary = HermesAmber,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = HermesAmberDark,
|
||||
onSecondaryContainer = HermesAmber80,
|
||||
tertiary = HermesAmberLight,
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnSurface,
|
||||
surface = DarkSurface,
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
error = Color(0xFFCF6679),
|
||||
onError = Color.Black,
|
||||
outline = Color(0xFF938F99),
|
||||
)
|
||||
|
||||
private val HermesLightColorScheme =
|
||||
lightColorScheme(
|
||||
primary = HermesPurple,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = HermesPurple80,
|
||||
onPrimaryContainer = HermesPurpleDark,
|
||||
secondary = HermesAmber,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = HermesAmber80,
|
||||
onSecondaryContainer = HermesAmberDark,
|
||||
tertiary = HermesAmberDark,
|
||||
background = LightBackground,
|
||||
onBackground = LightOnSurface,
|
||||
surface = LightSurface,
|
||||
onSurface = LightOnSurface,
|
||||
surfaceVariant = LightSurfaceVariant,
|
||||
onSurfaceVariant = LightOnSurfaceVariant,
|
||||
error = Color(0xFFB00020),
|
||||
onError = Color.White,
|
||||
outline = Color(0xFF79747E),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HermesControlTheme(
|
||||
themePreference: ThemePreference = LocalThemePreference.current,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val darkTheme =
|
||||
when (themePreference) {
|
||||
ThemePreference.SYSTEM -> isSystemInDarkTheme()
|
||||
ThemePreference.LIGHT -> false
|
||||
ThemePreference.DARK -> true
|
||||
}
|
||||
|
||||
val colorScheme = if (darkTheme) HermesDarkColorScheme else HermesLightColorScheme
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography =
|
||||
Typography(
|
||||
bodyLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.m57.hermescontrol.ui.achievements
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AchievementsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: AchievementsViewModel = viewModel { AchievementsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadAchievements()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Agent Achievements") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadAchievements() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading && state.achievements.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null && state.achievements.isEmpty()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IconButton(onClick = { viewModel.loadAchievements() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.achievements) { achievement ->
|
||||
val pct = achievement.progress_pct?.toFloat()?.div(100f) ?: 0f
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (achievement.unlocked) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = achievement.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
achievement.category?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Display Icon/Emoji/Tier
|
||||
val badgeText =
|
||||
when {
|
||||
achievement.unlocked -> achievement.tier ?: "🏆"
|
||||
achievement.discovered -> "🔒 Discovered"
|
||||
else -> "❓ Secret"
|
||||
}
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color =
|
||||
if (achievement.unlocked) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
achievement.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
|
||||
if (achievement.discovered && !achievement.unlocked) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text =
|
||||
"Progress: ${achievement.progress?.toInt() ?: 0} / " +
|
||||
"${achievement.next_threshold?.toInt() ?: 0}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
Text(
|
||||
text = "${achievement.progress_pct?.toInt() ?: 0}%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = { pct.coerceIn(0f, 1f) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.m57.hermescontrol.ui.achievements
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.Achievement
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class AchievementsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val achievements: List<Achievement> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
class AchievementsViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(AchievementsUiState())
|
||||
val uiState: StateFlow<AchievementsUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadAchievements() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getAchievements()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
achievements = response.body()?.achievements.orEmpty(),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load achievements: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load achievements: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.AssistantBubble
|
||||
import com.m57.hermescontrol.theme.AssistantBubbleLight
|
||||
import com.m57.hermescontrol.theme.StatusGreen
|
||||
import com.m57.hermescontrol.theme.StatusRed
|
||||
import com.m57.hermescontrol.theme.SystemMessageColor
|
||||
import com.m57.hermescontrol.theme.ToolChipColor
|
||||
import com.m57.hermescontrol.theme.ToolChipColorLight
|
||||
import com.m57.hermescontrol.theme.UserBubble
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ChatBubble(
|
||||
message: ChatMessage,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val maxBubbleWidth = screenWidth * 0.80f
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter =
|
||||
fadeIn() +
|
||||
expandVertically(
|
||||
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
|
||||
),
|
||||
) {
|
||||
when (message.role) {
|
||||
MessageRole.USER -> UserBubble(message, maxBubbleWidth, modifier)
|
||||
MessageRole.ASSISTANT -> AssistantBubble(message, maxBubbleWidth, isDarkTheme, modifier)
|
||||
MessageRole.SYSTEM -> SystemBubble(message, modifier)
|
||||
MessageRole.TOOL -> ToolBubble(message, isDarkTheme, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UserBubble(
|
||||
message: ChatMessage,
|
||||
maxWidth: androidx.compose.ui.unit.Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 16.dp,
|
||||
topEnd = 16.dp,
|
||||
bottomStart = 16.dp,
|
||||
bottomEnd = 4.dp,
|
||||
),
|
||||
),
|
||||
color = UserBubble,
|
||||
tonalElevation = 0.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = message.content,
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = formatTimestamp(message.timestamp),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantBubble(
|
||||
message: ChatMessage,
|
||||
maxWidth: androidx.compose.ui.unit.Dp,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bubbleColor = if (isDarkTheme) AssistantBubble else AssistantBubbleLight
|
||||
val textColor = MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 3.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.widthIn(max = maxWidth)
|
||||
.animateContentSize()
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 4.dp,
|
||||
topEnd = 16.dp,
|
||||
bottomStart = 16.dp,
|
||||
bottomEnd = 16.dp,
|
||||
),
|
||||
),
|
||||
color = bubbleColor,
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(12.dp)) {
|
||||
SelectionContainer {
|
||||
RichText(
|
||||
text = message.content,
|
||||
textColor = textColor,
|
||||
)
|
||||
}
|
||||
if (!message.isStreaming) {
|
||||
Text(
|
||||
text = formatTimestamp(message.timestamp),
|
||||
color = textColor.copy(alpha = 0.5f),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.End)
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SystemBubble(
|
||||
message: ChatMessage,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 6.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = message.content,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = SystemMessageColor,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolBubble(
|
||||
message: ChatMessage,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
|
||||
val contentColor = if (isDarkTheme) Color.White else Color.Black
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Card(
|
||||
onClick = { expanded = !expanded },
|
||||
colors = CardDefaults.cardColors(containerColor = chipColor),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.animateContentSize()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val (icon, tint) =
|
||||
when (message.toolStatus) {
|
||||
ToolStatus.RUNNING -> Icons.Filled.Build to MaterialTheme.colorScheme.secondary
|
||||
ToolStatus.COMPLETED -> Icons.Filled.CheckCircle to StatusGreen
|
||||
ToolStatus.FAILED -> Icons.Filled.Error to StatusRed
|
||||
null -> Icons.Filled.Build to contentColor.copy(alpha = 0.6f)
|
||||
}
|
||||
|
||||
if (message.toolStatus == ToolStatus.RUNNING) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.secondary,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp),
|
||||
tint = tint,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = message.toolName ?: "Tool",
|
||||
style =
|
||||
MaterialTheme.typography.labelMedium.copy(
|
||||
color = contentColor,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded && message.content.isNotBlank()) {
|
||||
Text(
|
||||
text = message.content,
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 11.sp,
|
||||
),
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple rich text renderer supporting **bold**, `inline code`, and ```code blocks```.
|
||||
*/
|
||||
@Composable
|
||||
private fun RichText(
|
||||
text: String,
|
||||
textColor: Color,
|
||||
) {
|
||||
val annotated =
|
||||
remember(text) {
|
||||
buildAnnotatedString {
|
||||
var i = 0
|
||||
val src = text
|
||||
while (i < src.length) {
|
||||
when {
|
||||
// Code block: ```...```
|
||||
src.startsWith("```", i) -> {
|
||||
val end = src.indexOf("```", i + 3)
|
||||
if (end != -1) {
|
||||
val code = src.substring(i + 3, end).trimStart('\n').trimEnd('\n')
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
background = textColor.copy(alpha = 0.08f),
|
||||
),
|
||||
) {
|
||||
append(code)
|
||||
}
|
||||
i = end + 3
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Inline code: `...`
|
||||
src[i] == '`' -> {
|
||||
val end = src.indexOf('`', i + 1)
|
||||
if (end != -1) {
|
||||
withStyle(
|
||||
SpanStyle(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 13.sp,
|
||||
background = textColor.copy(alpha = 0.08f),
|
||||
),
|
||||
) {
|
||||
append(src.substring(i + 1, end))
|
||||
}
|
||||
i = end + 1
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Bold: **...**
|
||||
src.startsWith("**", i) -> {
|
||||
val end = src.indexOf("**", i + 2)
|
||||
if (end != -1) {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(src.substring(i + 2, end))
|
||||
}
|
||||
i = end + 2
|
||||
} else {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
append(src[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotated,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = textColor),
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
val sdf = SimpleDateFormat("HH:mm", Locale.getDefault())
|
||||
return sdf.format(Date(timestamp))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class ChatMessage(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val role: MessageRole,
|
||||
val content: String,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val isStreaming: Boolean = false,
|
||||
val toolName: String? = null,
|
||||
val toolStatus: ToolStatus? = null,
|
||||
)
|
||||
|
||||
enum class MessageRole {
|
||||
USER,
|
||||
ASSISTANT,
|
||||
SYSTEM,
|
||||
TOOL,
|
||||
}
|
||||
|
||||
enum class ToolStatus {
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.theme.StatusGreen
|
||||
import com.m57.hermescontrol.theme.StatusRed
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
onNavigateToSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: ChatViewModel = viewModel { ChatViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
var inputText by rememberSaveable { mutableStateOf("") }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val isDark = isSystemInDarkTheme()
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
LaunchedEffect(state.messages.size, state.currentStreamingText) {
|
||||
if (state.messages.isNotEmpty()) {
|
||||
listState.animateScrollToItem(state.messages.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Show error as snackbar
|
||||
LaunchedEffect(state.errorMessage) {
|
||||
state.errorMessage?.let { error ->
|
||||
snackbarHostState.showSnackbar(error)
|
||||
viewModel.clearError()
|
||||
}
|
||||
}
|
||||
|
||||
// Clarify dialog
|
||||
state.clarifyRequest?.let { clarify ->
|
||||
ClarifyDialog(
|
||||
clarify = clarify,
|
||||
onOptionSelected = viewModel::respondToClarify,
|
||||
onDismiss = viewModel::dismissClarify,
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Hermes",
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// Connection status dot
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(10.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (state.isConnected) StatusGreen else StatusRed),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Session picker
|
||||
Box {
|
||||
IconButton(onClick = {
|
||||
viewModel.loadSessions()
|
||||
viewModel.toggleSessionPicker()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.History,
|
||||
contentDescription = "Sessions",
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = state.showSessionPicker,
|
||||
onDismissRequest = { viewModel.toggleSessionPicker() },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
Icons.Filled.Add,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("New Session")
|
||||
}
|
||||
},
|
||||
onClick = {
|
||||
viewModel.createNewSession()
|
||||
viewModel.toggleSessionPicker()
|
||||
},
|
||||
)
|
||||
if (state.sessions.isNotEmpty()) {
|
||||
HorizontalDivider()
|
||||
}
|
||||
state.sessions.forEach { session ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
text = session.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
text = "${session.messageCount} messages",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = { viewModel.switchSession(session.id) },
|
||||
trailingIcon = {
|
||||
if (session.id == state.currentSessionId) {
|
||||
Icon(
|
||||
Icons.Filled.ArrowDropDown,
|
||||
contentDescription = "Current",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = "Settings",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
snackbarHost = {
|
||||
SnackbarHost(snackbarHostState) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
ChatInputBar(
|
||||
inputText = inputText,
|
||||
onInputChange = { inputText = it },
|
||||
onSend = {
|
||||
viewModel.sendMessage(inputText)
|
||||
inputText = ""
|
||||
},
|
||||
onInterrupt = viewModel::interruptSession,
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isConnected = state.isConnected,
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.messages.isEmpty() && !state.isLoading) {
|
||||
// Empty state
|
||||
EmptyState()
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
items(
|
||||
items = state.messages,
|
||||
key = { it.id },
|
||||
) { message ->
|
||||
ChatBubble(
|
||||
message = message,
|
||||
isDarkTheme = isDark,
|
||||
)
|
||||
}
|
||||
|
||||
// Thinking indicator
|
||||
if (state.isThinking) {
|
||||
item(key = "thinking") {
|
||||
ThinkingIndicator(state.thinkingText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading overlay
|
||||
AnimatedVisibility(
|
||||
visible = state.isLoading,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.7f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Card(
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(4.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Connecting to Hermes…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyState() {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "⚡",
|
||||
fontSize = 48.sp,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Ready to chat",
|
||||
style =
|
||||
MaterialTheme.typography.titleMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = "Send a message to start a conversation",
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThinkingIndicator(thinkingText: String) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "thinking")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f,
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
infiniteRepeatable(
|
||||
animation = tween(600, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "thinking_alpha",
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Card(
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "🤔",
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.graphicsLayer { this.alpha = alpha },
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text =
|
||||
if (thinkingText.isNotBlank()) {
|
||||
val display = thinkingText.takeLast(100)
|
||||
"Thinking: $display…"
|
||||
} else {
|
||||
"Thinking…"
|
||||
},
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines = 2,
|
||||
modifier = Modifier.animateContentSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatInputBar(
|
||||
inputText: String,
|
||||
onInputChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onInterrupt: () -> Unit,
|
||||
isAgentTyping: Boolean,
|
||||
isConnected: Boolean,
|
||||
) {
|
||||
val canSend = inputText.isNotBlank() && !isAgentTyping && isConnected
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
enter = slideInVertically(initialOffsetY = { it }),
|
||||
exit = slideOutVertically(targetOffsetY = { it }),
|
||||
) {
|
||||
Card(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.imePadding(),
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = inputText,
|
||||
onValueChange = onInputChange,
|
||||
modifier =
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(max = 140.dp),
|
||||
placeholder = {
|
||||
Text(
|
||||
if (!isConnected) {
|
||||
"Not connected…"
|
||||
} else if (isAgentTyping) {
|
||||
"Waiting for response…"
|
||||
} else {
|
||||
"Type a message…"
|
||||
},
|
||||
)
|
||||
},
|
||||
enabled = isConnected,
|
||||
maxLines = 5,
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
if (isAgentTyping) {
|
||||
// Interrupt button
|
||||
FilledTonalButton(
|
||||
onClick = onInterrupt,
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Stop,
|
||||
contentDescription = "Interrupt",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Send button
|
||||
FilledTonalButton(
|
||||
onClick = onSend,
|
||||
enabled = canSend,
|
||||
modifier = Modifier.size(48.dp),
|
||||
shape = CircleShape,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = "Send",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClarifyDialog(
|
||||
clarify: ClarifyUi,
|
||||
onOptionSelected: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Clarification Needed") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(text = clarify.text)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
clarify.options.forEach { option ->
|
||||
FilledTonalButton(
|
||||
onClick = { onOptionSelected(option) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(option)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("Dismiss")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class ChatUiState(
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
val currentSessionId: String? = null,
|
||||
val sessions: List<SessionUi> = emptyList(),
|
||||
val isConnected: Boolean = false,
|
||||
val isAgentTyping: Boolean = false,
|
||||
val isThinking: Boolean = false,
|
||||
val thinkingText: String = "",
|
||||
val isLoading: Boolean = false,
|
||||
val currentStreamingText: String = "",
|
||||
val errorMessage: String? = null,
|
||||
val clarifyRequest: ClarifyUi? = null,
|
||||
val showSessionPicker: Boolean = false,
|
||||
)
|
||||
|
||||
data class SessionUi(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val messageCount: Int = 0,
|
||||
)
|
||||
|
||||
data class ClarifyUi(
|
||||
val text: String,
|
||||
val options: List<String>,
|
||||
)
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val wsClient = HermesWsClient
|
||||
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
|
||||
|
||||
init {
|
||||
connectWebSocket()
|
||||
}
|
||||
|
||||
private fun connectWebSocket() {
|
||||
val token = AuthManager.getToken() ?: return
|
||||
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.connect()
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
wsClient.events.collect { event ->
|
||||
handleWsEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWsEvent(event: WsEvent) {
|
||||
when (event) {
|
||||
is WsEvent.GatewayReady -> {
|
||||
_uiState.update { it.copy(isConnected = true, isLoading = false) }
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
// Create a new session if none exists
|
||||
if (_uiState.value.currentSessionId == null) {
|
||||
createNewSession()
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.SessionInfo -> {
|
||||
// We may receive session info after creation
|
||||
}
|
||||
|
||||
is WsEvent.MessageStart -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isAgentTyping = true,
|
||||
currentStreamingText = "",
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.MessageToken -> {
|
||||
_uiState.update { state ->
|
||||
val newText = state.currentStreamingText + event.token
|
||||
// Update or create the streaming message
|
||||
val messages = state.messages.toMutableList()
|
||||
val streamingIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.ASSISTANT && it.isStreaming
|
||||
}
|
||||
if (streamingIdx >= 0) {
|
||||
messages[streamingIdx] = messages[streamingIdx].copy(content = newText)
|
||||
} else {
|
||||
messages.add(
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = newText,
|
||||
isStreaming = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
state.copy(
|
||||
messages = messages,
|
||||
currentStreamingText = newText,
|
||||
isThinking = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ThinkingDelta -> {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
isThinking = true,
|
||||
thinkingText = state.thinkingText + event.token,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.MessageComplete -> {
|
||||
_uiState.update { state ->
|
||||
val messages = state.messages.toMutableList()
|
||||
val streamingIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.ASSISTANT && it.isStreaming
|
||||
}
|
||||
if (streamingIdx >= 0) {
|
||||
messages[streamingIdx] =
|
||||
messages[streamingIdx].copy(
|
||||
content = event.text,
|
||||
isStreaming = false,
|
||||
)
|
||||
} else {
|
||||
messages.add(
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = event.text,
|
||||
),
|
||||
)
|
||||
}
|
||||
state.copy(
|
||||
messages = messages,
|
||||
isAgentTyping = false,
|
||||
currentStreamingText = "",
|
||||
isThinking = false,
|
||||
thinkingText = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.MessageDone -> {
|
||||
_uiState.update {
|
||||
it.copy(isAgentTyping = false, isThinking = false, thinkingText = "")
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ToolStart -> {
|
||||
val toolMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.TOOL,
|
||||
content = event.data.toString(),
|
||||
toolName = event.name,
|
||||
toolStatus = ToolStatus.RUNNING,
|
||||
)
|
||||
_uiState.update { state ->
|
||||
state.copy(messages = state.messages + toolMessage)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ToolComplete -> {
|
||||
_uiState.update { state ->
|
||||
val messages = state.messages.toMutableList()
|
||||
val toolIdx =
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.TOOL &&
|
||||
it.toolName == event.name &&
|
||||
it.toolStatus == ToolStatus.RUNNING
|
||||
}
|
||||
if (toolIdx >= 0) {
|
||||
messages[toolIdx] =
|
||||
messages[toolIdx].copy(
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
content = event.data.toString(),
|
||||
)
|
||||
}
|
||||
state.copy(messages = messages)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.ClarifyRequest -> {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
clarifyRequest =
|
||||
ClarifyUi(
|
||||
text = event.text.orEmpty(),
|
||||
options = event.options.orEmpty(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.StatusUpdate -> {
|
||||
// Can be used for general status
|
||||
}
|
||||
|
||||
is WsEvent.SessionUpdated -> {
|
||||
// Refresh sessions list
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
is WsEvent.RpcResult -> {
|
||||
handleRpcResult(event.id, event.result)
|
||||
}
|
||||
|
||||
is WsEvent.RpcError -> {
|
||||
handleRpcError(event.id, event.error)
|
||||
}
|
||||
|
||||
is WsEvent.Unknown -> {
|
||||
// Handle or ignore unknown/unhandled events
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun handleRpcResult(
|
||||
id: String,
|
||||
result: Any?,
|
||||
) {
|
||||
val method = pendingRequests.remove(id) ?: return
|
||||
|
||||
when (method) {
|
||||
WsMethods.SESSION_CREATE -> {
|
||||
val resultMap = result as? Map<String, Any?> ?: return
|
||||
val sessionId = resultMap["session_id"] as? String ?: return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
currentSessionId = sessionId,
|
||||
isLoading = false,
|
||||
messages = it.messages,
|
||||
)
|
||||
}
|
||||
addSystemMessage("Session created")
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
WsMethods.SESSION_LIST -> {
|
||||
val resultMap = result as? Map<String, Any?> ?: return
|
||||
val sessionsList = resultMap["sessions"] as? List<Map<String, Any?>> ?: return
|
||||
val sessions =
|
||||
sessionsList.map { s ->
|
||||
SessionUi(
|
||||
id = s["id"] as? String ?: "",
|
||||
title = s["title"] as? String ?: "Untitled",
|
||||
messageCount = (s["message_count"] as? Double)?.toInt() ?: 0,
|
||||
)
|
||||
}
|
||||
_uiState.update { it.copy(sessions = sessions) }
|
||||
}
|
||||
|
||||
WsMethods.SESSION_RESUME -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
addSystemMessage("Session resumed")
|
||||
}
|
||||
|
||||
WsMethods.SESSION_INTERRUPT -> {
|
||||
_uiState.update {
|
||||
it.copy(isAgentTyping = false, isThinking = false)
|
||||
}
|
||||
addSystemMessage("Session interrupted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRpcError(
|
||||
id: String,
|
||||
error: Any?,
|
||||
) {
|
||||
val method = pendingRequests.remove(id)
|
||||
val errorMsg =
|
||||
when (error) {
|
||||
is Map<*, *> -> error["message"] as? String ?: error.toString()
|
||||
else -> error.toString()
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error${if (method != null) " ($method)" else ""}: $errorMsg",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
|
||||
// Add user message to UI
|
||||
val userMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.USER,
|
||||
content = text,
|
||||
)
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
messages = state.messages + userMessage,
|
||||
isAgentTyping = true,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val requestId = wsClient.sendMessage(sessionId, text)
|
||||
pendingRequests[requestId] = WsMethods.PROMPT_SUBMIT
|
||||
}
|
||||
}
|
||||
|
||||
fun interruptSession() {
|
||||
val sessionId = _uiState.value.currentSessionId ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val requestId =
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_INTERRUPT,
|
||||
mapOf("session_id" to sessionId),
|
||||
)
|
||||
pendingRequests[requestId] = WsMethods.SESSION_INTERRUPT
|
||||
}
|
||||
}
|
||||
|
||||
fun createNewSession() {
|
||||
_uiState.update { it.copy(isLoading = true, messages = emptyList()) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val requestId = wsClient.send(WsMethods.SESSION_CREATE)
|
||||
pendingRequests[requestId] = WsMethods.SESSION_CREATE
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSessions() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val requestId = wsClient.send(WsMethods.SESSION_LIST)
|
||||
pendingRequests[requestId] = WsMethods.SESSION_LIST
|
||||
}
|
||||
}
|
||||
|
||||
fun switchSession(sessionId: String) {
|
||||
if (sessionId == _uiState.value.currentSessionId) return
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
messages = emptyList(),
|
||||
currentSessionId = sessionId,
|
||||
showSessionPicker = false,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val requestId =
|
||||
wsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to sessionId),
|
||||
)
|
||||
pendingRequests[requestId] = WsMethods.SESSION_RESUME
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSessionPicker() {
|
||||
_uiState.update { it.copy(showSessionPicker = !it.showSessionPicker) }
|
||||
}
|
||||
|
||||
fun dismissClarify() {
|
||||
_uiState.update { it.copy(clarifyRequest = null) }
|
||||
}
|
||||
|
||||
fun respondToClarify(option: String) {
|
||||
_uiState.update { it.copy(clarifyRequest = null) }
|
||||
sendMessage(option)
|
||||
}
|
||||
|
||||
fun clearError() {
|
||||
_uiState.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
|
||||
fun reconnect() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isConnected = false,
|
||||
isLoading = true,
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.disconnect()
|
||||
}
|
||||
viewModelScope.launch {
|
||||
delay(500)
|
||||
connectWebSocket()
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSystemMessage(text: String) {
|
||||
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||
_uiState.update { it.copy(messages = it.messages + msg) }
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
wsClient.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.m57.hermescontrol.ui.commands
|
||||
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Analytics
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.AssistChipDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
data class QuickCommand(
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
val action: () -> Unit,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CommandPanel(
|
||||
onStatusClick: () -> Unit,
|
||||
onSessionsClick: () -> Unit,
|
||||
onSystemStatsClick: () -> Unit,
|
||||
onNewSessionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val commands =
|
||||
listOf(
|
||||
QuickCommand("Status", Icons.Filled.Info, onStatusClick),
|
||||
QuickCommand("Sessions", Icons.Filled.History, onSessionsClick),
|
||||
QuickCommand("System Stats", Icons.Filled.Analytics, onSystemStatsClick),
|
||||
QuickCommand("New Session", Icons.Filled.Add, onNewSessionClick),
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(0.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Quick Commands",
|
||||
style =
|
||||
MaterialTheme.typography.labelSmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 6.dp),
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
commands.forEach { cmd ->
|
||||
AssistChip(
|
||||
onClick = cmd.action,
|
||||
label = { Text(cmd.label, style = MaterialTheme.typography.labelMedium) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = cmd.icon,
|
||||
contentDescription = cmd.label,
|
||||
modifier = Modifier.padding(0.dp),
|
||||
)
|
||||
},
|
||||
colors =
|
||||
AssistChipDefaults.assistChipColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.m57.hermescontrol.ui.connect
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
onConnected: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: ConnectViewModel = viewModel { ConnectViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(state.connectionSuccess) {
|
||||
if (state.connectionSuccess) {
|
||||
onConnected()
|
||||
}
|
||||
}
|
||||
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
// Branding
|
||||
Text(
|
||||
text = "⚡",
|
||||
fontSize = 64.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Hermes",
|
||||
style =
|
||||
MaterialTheme.typography.headlineLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = "AI Agent Control",
|
||||
style =
|
||||
MaterialTheme.typography.titleMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
|
||||
// Connection Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Connect to Hermes",
|
||||
style =
|
||||
MaterialTheme.typography.titleMedium.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
|
||||
// Token field
|
||||
OutlinedTextField(
|
||||
value = state.token,
|
||||
onValueChange = viewModel::onTokenChange,
|
||||
label = { Text("Auth Token") },
|
||||
placeholder = { Text("Enter your API token") },
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription =
|
||||
if (passwordVisible) {
|
||||
"Hide token"
|
||||
} else {
|
||||
"Show token"
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
|
||||
// Host field
|
||||
OutlinedTextField(
|
||||
value = state.host,
|
||||
onValueChange = viewModel::onHostChange,
|
||||
label = { Text("Host") },
|
||||
placeholder = { Text("127.0.0.1") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
|
||||
// Port field
|
||||
OutlinedTextField(
|
||||
value = state.port,
|
||||
onValueChange = viewModel::onPortChange,
|
||||
label = { Text("Port") },
|
||||
placeholder = { Text("9119") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
|
||||
// Error message
|
||||
AnimatedVisibility(
|
||||
visible = state.errorMessage != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
state.errorMessage?.let { error ->
|
||||
Card(
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = error,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connect button
|
||||
Button(
|
||||
onClick = viewModel::connect,
|
||||
enabled = !state.isConnecting,
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
if (state.isConnecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Connect",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.m57.hermescontrol.ui.connect
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ConnectUiState(
|
||||
val token: String = "",
|
||||
val host: String = "127.0.0.1",
|
||||
val port: String = "9119",
|
||||
val isConnecting: Boolean = false,
|
||||
val connectionSuccess: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
class ConnectViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ConnectUiState())
|
||||
val uiState: StateFlow<ConnectUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
// Load saved values
|
||||
val savedToken = AuthManager.getToken() ?: ""
|
||||
val savedHost = AuthManager.getHost()
|
||||
val savedPort = AuthManager.getPort()
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
token = savedToken,
|
||||
host = savedHost,
|
||||
port = savedPort.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTokenChange(value: String) {
|
||||
_uiState.update { it.copy(token = value.trim(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onHostChange(value: String) {
|
||||
_uiState.update { it.copy(host = value.trim(), errorMessage = null) }
|
||||
}
|
||||
|
||||
fun onPortChange(value: String) {
|
||||
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, errorMessage = null) }
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
val state = _uiState.value
|
||||
if (state.token.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Token is required") }
|
||||
return
|
||||
}
|
||||
if (state.host.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Host is required") }
|
||||
return
|
||||
}
|
||||
val port = state.port.toIntOrNull()
|
||||
if (port == null || port !in 1..65535) {
|
||||
_uiState.update { it.copy(errorMessage = "Port must be between 1 and 65535") }
|
||||
return
|
||||
}
|
||||
|
||||
_uiState.update { it.copy(isConnecting = true, errorMessage = null) }
|
||||
|
||||
// Save settings before testing so ApiClient uses the right base URL
|
||||
AuthManager.setToken(state.token)
|
||||
AuthManager.setHost(state.host)
|
||||
AuthManager.setPort(port)
|
||||
ApiClient.rebuild()
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getStatus()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(isConnecting = false, connectionSuccess = true, errorMessage = null)
|
||||
}
|
||||
} else {
|
||||
val code = response.code()
|
||||
val msg =
|
||||
when (code) {
|
||||
401 -> {
|
||||
AuthManager.setToken(null)
|
||||
"Invalid token (401 Unauthorized)"
|
||||
}
|
||||
|
||||
403 -> {
|
||||
"Access denied (403 Forbidden)"
|
||||
}
|
||||
|
||||
else -> {
|
||||
"Server returned HTTP $code"
|
||||
}
|
||||
}
|
||||
_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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.m57.hermescontrol.ui.cron
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CronJobsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: CronJobsViewModel = viewModel { CronJobsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadCronJobs()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Cron Jobs") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadCronJobs() }) {
|
||||
Text("Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null) {
|
||||
Text(
|
||||
text = state.errorMessage ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.jobs) { job ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(text = job.name, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(text = "Schedule: ${job.schedule}", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(text = "Status: ${job.state}", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
text = "Last Run: ${job.last_run_status ?: "Never"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Next Run: ${job.next_run ?: "N/A"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (job.state == "active") {
|
||||
Button(onClick = { viewModel.pauseCronJob(job.id) }) {
|
||||
Text("Pause")
|
||||
}
|
||||
} else {
|
||||
Button(onClick = { viewModel.resumeCronJob(job.id) }) {
|
||||
Text("Resume")
|
||||
}
|
||||
}
|
||||
Button(onClick = { viewModel.triggerCronJob(job.id) }) {
|
||||
Text("Trigger")
|
||||
}
|
||||
Button(
|
||||
onClick = { viewModel.deleteCronJob(job.id) },
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
),
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.m57.hermescontrol.ui.cron
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.CronJob
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class CronJobsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val jobs: List<CronJob> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class CronJobsViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(CronJobsUiState())
|
||||
val uiState: StateFlow<CronJobsUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadCronJobs() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getCronJobs()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update { it.copy(isLoading = false, jobs = response.body().orEmpty()) }
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load cron jobs: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load cron jobs: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pauseCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
// Optimistic update
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
jobs =
|
||||
state.jobs.map {
|
||||
if (it.id == id) it.copy(state = "paused") else it
|
||||
},
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.pauseCronJob(id)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
revertJobs(originalJobs, "Failed to pause cron job: HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
revertJobs(originalJobs, "Failed to pause cron job: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resumeCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
// Optimistic update
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
jobs =
|
||||
state.jobs.map {
|
||||
if (it.id == id) it.copy(state = "active") else it
|
||||
},
|
||||
)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.resumeCronJob(id)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
revertJobs(originalJobs, "Failed to resume cron job: HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
revertJobs(originalJobs, "Failed to resume cron job: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerCronJob(id: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.triggerCronJob(id)
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update { it.copy(toastMessage = "Job triggered successfully") }
|
||||
} else {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: HTTP ${response.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to trigger cron job: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteCronJob(id: String) {
|
||||
val originalJobs = _uiState.value.jobs
|
||||
// Optimistic update
|
||||
_uiState.update { state ->
|
||||
state.copy(jobs = state.jobs.filter { it.id != id })
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.deleteCronJob(id)
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
revertJobs(originalJobs, "Failed to delete cron job: HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
revertJobs(originalJobs, "Failed to delete cron job: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertJobs(
|
||||
originalJobs: List<CronJob>,
|
||||
errorMsg: String,
|
||||
) {
|
||||
_uiState.update { it.copy(jobs = originalJobs, toastMessage = errorMsg) }
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package com.m57.hermescontrol.ui.gateway
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GatewayScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadStatus()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Gateway Control") },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { viewModel.loadStatus() },
|
||||
enabled = !state.isLoading,
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading && state.status == null) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null && state.status == null) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = state.errorMessage ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = { viewModel.loadStatus() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val status = state.status
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val isRunning = status?.gateway_running == true
|
||||
|
||||
// Status Overview Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isRunning) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = if (isRunning) "GATEWAY RUNNING" else "GATEWAY STOPPED",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color =
|
||||
if (isRunning) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onErrorContainer
|
||||
},
|
||||
)
|
||||
status?.version?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Version: $it",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
if (isRunning) {
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.7f)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action Controls Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Controls",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Button(
|
||||
onClick = { viewModel.startGateway() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = !isRunning && !state.isActionRunning,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF4CAF50), // Green for start
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Start")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.stopGateway() },
|
||||
modifier = Modifier.weight(1f),
|
||||
enabled = isRunning && !state.isActionRunning,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFFF44336), // Red for stop
|
||||
contentColor = Color.White,
|
||||
),
|
||||
) {
|
||||
Text("Stop")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.restartGateway() },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isActionRunning,
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
),
|
||||
) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Restart")
|
||||
}
|
||||
|
||||
if (state.isActionRunning) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Platforms Card
|
||||
status?.gateway_platforms?.let { platforms ->
|
||||
if (platforms.isNotEmpty()) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Active Platforms",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
platforms.forEach { (platform, pStatus) ->
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = platform.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
val stateText = pStatus.state?.uppercase() ?: "UNKNOWN"
|
||||
val badgeColor =
|
||||
when (stateText) {
|
||||
"RUNNING" -> Color(0xFF4CAF50)
|
||||
"STOPPED" -> Color(0xFFF44336)
|
||||
"ERROR" -> Color(0xFFFF9800)
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
Text(
|
||||
text = stateText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = badgeColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.m57.hermescontrol.ui.gateway
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.StatusResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class GatewayUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isActionRunning: Boolean = false,
|
||||
val status: StatusResponse? = null,
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class GatewayViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(GatewayUiState())
|
||||
val uiState: StateFlow<GatewayUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadStatus() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getStatus()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update { it.copy(isLoading = false, status = response.body()) }
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load status: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load status: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startGateway() {
|
||||
runGatewayAction("start") { ApiClient.hermesApi.startGateway() }
|
||||
}
|
||||
|
||||
fun stopGateway() {
|
||||
runGatewayAction("stop") { ApiClient.hermesApi.stopGateway() }
|
||||
}
|
||||
|
||||
fun restartGateway() {
|
||||
runGatewayAction("restart") { ApiClient.hermesApi.restartGateway() }
|
||||
}
|
||||
|
||||
private fun runGatewayAction(
|
||||
actionName: String,
|
||||
apiCall: suspend () -> retrofit2.Response<Unit>,
|
||||
) {
|
||||
_uiState.update { it.copy(isActionRunning = true) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response = withContext(Dispatchers.IO) { apiCall() }
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Gateway ${actionName}ed successfully",
|
||||
)
|
||||
}
|
||||
loadStatus()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Failed to $actionName gateway: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isActionRunning = false,
|
||||
toastMessage = "Failed to $actionName gateway: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package com.m57.hermescontrol.ui.profiles
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ProfilesScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: ProfilesViewModel = viewModel { ProfilesViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
var soulEditProfileName by remember { mutableStateOf<String?>(null) }
|
||||
var modelEditProfileName by remember { mutableStateOf<String?>(null) }
|
||||
var tempModelProvider by remember { mutableStateOf("") }
|
||||
var tempModelName by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadProfiles()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Agent Profiles") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadProfiles() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading && state.profiles.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null && state.profiles.isEmpty()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Button(onClick = { viewModel.loadProfiles() }) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.profiles) { profile ->
|
||||
val isActive = profile.name == state.activeProfileName
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isActive) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
onClick = {
|
||||
if (!isActive) {
|
||||
viewModel.selectActiveProfile(profile.name)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = profile.name.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
profile.description?.let {
|
||||
if (it.isNotBlank()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isActive) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = "Active Profile",
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Model: ${profile.model ?: "None"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
Text(
|
||||
text = "Provider: ${profile.provider ?: "None"}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
soulEditProfileName = profile.name
|
||||
viewModel.loadSoul(profile.name)
|
||||
},
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Edit,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.width(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("Edit Soul")
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
modelEditProfileName = profile.name
|
||||
tempModelProvider = profile.provider ?: ""
|
||||
tempModelName = profile.model ?: ""
|
||||
},
|
||||
) {
|
||||
Text("Set Model")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Soul Edit Dialog
|
||||
soulEditProfileName?.let { profileName ->
|
||||
var soulText by remember(state.selectedSoulContent) {
|
||||
mutableStateOf(state.selectedSoulContent ?: "")
|
||||
}
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
soulEditProfileName = null
|
||||
viewModel.closeSoulDialog()
|
||||
},
|
||||
title = { Text("Edit Soul ($profileName)") },
|
||||
text = {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
if (state.isLoadingSoul) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = soulText,
|
||||
onValueChange = { soulText = it },
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
label = { Text("Soul/Persona Prompts") },
|
||||
maxLines = 10,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.saveSoul(profileName, soulText)
|
||||
soulEditProfileName = null
|
||||
},
|
||||
enabled = !state.isLoadingSoul,
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
soulEditProfileName = null
|
||||
viewModel.closeSoulDialog()
|
||||
},
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Model Edit Dialog
|
||||
modelEditProfileName?.let { profileName ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { modelEditProfileName = null },
|
||||
title = { Text("Switch Model ($profileName)") },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = tempModelProvider,
|
||||
onValueChange = { tempModelProvider = it },
|
||||
label = { Text("Provider (e.g. nvidia, openai, local)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = tempModelName,
|
||||
onValueChange = { tempModelName = it },
|
||||
label = { Text("Model Name") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
viewModel.updateModel(profileName, tempModelProvider, tempModelName)
|
||||
modelEditProfileName = null
|
||||
},
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { modelEditProfileName = null }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.m57.hermescontrol.ui.profiles
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.data.model.SetActiveProfileRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileDescriptionRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileModelRequest
|
||||
import com.m57.hermescontrol.data.model.UpdateProfileSoulRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ProfilesUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val profiles: List<ProfileInfo> = emptyList(),
|
||||
val activeProfileName: String? = null,
|
||||
val selectedSoulContent: String? = null,
|
||||
val isLoadingSoul: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class ProfilesViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ProfilesUiState())
|
||||
val uiState: StateFlow<ProfilesUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadProfiles() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val profilesRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getProfiles() }
|
||||
val activeRes = withContext(Dispatchers.IO) { ApiClient.hermesApi.getActiveProfile() }
|
||||
|
||||
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) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Error loading profiles: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun selectActiveProfile(name: String) {
|
||||
val originalActive = _uiState.value.activeProfileName
|
||||
// Optimistically update active profile name
|
||||
_uiState.update { it.copy(activeProfileName = name) }
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val res =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.setActiveProfile(SetActiveProfileRequest(name))
|
||||
}
|
||||
if (res.isSuccessful) {
|
||||
_uiState.update { it.copy(toastMessage = "Switched to profile $name") }
|
||||
loadProfiles()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeProfileName = originalActive,
|
||||
toastMessage = "Failed to switch profile: HTTP ${res.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
activeProfileName = originalActive,
|
||||
toastMessage = "Failed to switch profile: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSoul(profileName: String) {
|
||||
_uiState.update { it.copy(isLoadingSoul = true, selectedSoulContent = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val res =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getProfileSoul(profileName)
|
||||
}
|
||||
if (res.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingSoul = false,
|
||||
selectedSoulContent = res.body()?.content ?: "",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingSoul = false,
|
||||
toastMessage = "Failed to load soul: HTTP ${res.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoadingSoul = false,
|
||||
toastMessage = "Failed to load soul: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveSoul(
|
||||
profileName: String,
|
||||
content: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val res =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.updateProfileSoul(profileName, UpdateProfileSoulRequest(content))
|
||||
}
|
||||
if (res.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
selectedSoulContent = null,
|
||||
toastMessage = "Soul updated successfully",
|
||||
)
|
||||
}
|
||||
loadProfiles()
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to save soul: HTTP ${res.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
toastMessage = "Failed to save soul: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateModel(
|
||||
profileName: String,
|
||||
provider: String,
|
||||
model: String,
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val res =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.updateProfileModel(profileName, UpdateProfileModelRequest(provider, model))
|
||||
}
|
||||
if (res.isSuccessful) {
|
||||
_uiState.update { it.copy(toastMessage = "Model settings updated") }
|
||||
loadProfiles()
|
||||
} else {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to update model settings: HTTP ${res.code()}") }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(toastMessage = "Failed to update model settings: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
|
||||
fun closeSoulDialog() {
|
||||
_uiState.update { it.copy(selectedSoulContent = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package com.m57.hermescontrol.ui.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SettingsViewModel = viewModel { SettingsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
"Settings",
|
||||
style =
|
||||
MaterialTheme.typography.titleLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
},
|
||||
colors =
|
||||
TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Connection section
|
||||
SectionCard(title = "Connection") {
|
||||
OutlinedTextField(
|
||||
value = state.host,
|
||||
onValueChange = viewModel::onHostChange,
|
||||
label = { Text("Host") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.port,
|
||||
onValueChange = viewModel::onPortChange,
|
||||
label = { Text("Port") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.token,
|
||||
onValueChange = viewModel::onTokenChange,
|
||||
label = { Text("Auth Token") },
|
||||
singleLine = true,
|
||||
visualTransformation =
|
||||
if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector =
|
||||
if (passwordVisible) {
|
||||
Icons.Filled.Visibility
|
||||
} else {
|
||||
Icons.Filled.VisibilityOff
|
||||
},
|
||||
contentDescription =
|
||||
if (passwordVisible) {
|
||||
"Hide token"
|
||||
} else {
|
||||
"Show token"
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = MaterialTheme.colorScheme.primary,
|
||||
cursorColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Behavior section
|
||||
SectionCard(title = "Behavior") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "Auto-Reconnect",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = "Automatically reconnect on disconnect",
|
||||
style =
|
||||
MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = state.autoReconnect,
|
||||
onCheckedChange = viewModel::onAutoReconnectChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Appearance section
|
||||
SectionCard(title = "Appearance") {
|
||||
Text(
|
||||
text = "Theme",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
|
||||
ThemePreference.entries.forEachIndexed { index, pref ->
|
||||
SegmentedButton(
|
||||
selected = state.themePreference == pref,
|
||||
onClick = { viewModel.onThemeChange(pref) },
|
||||
shape =
|
||||
SegmentedButtonDefaults.itemShape(
|
||||
index = index,
|
||||
count = ThemePreference.entries.size,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
pref.name
|
||||
.lowercase()
|
||||
.replaceFirstChar { it.uppercase() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test result
|
||||
AnimatedVisibility(
|
||||
visible = state.testResult != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
state.testResult?.let { result ->
|
||||
Card(
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (result.startsWith("✅")) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
},
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = result,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save indicator
|
||||
AnimatedVisibility(
|
||||
visible = state.isSaved,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
Text(
|
||||
text = "✅ Settings saved",
|
||||
style =
|
||||
MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = viewModel::testConnection,
|
||||
enabled = !state.isTesting,
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
if (state.isTesting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
Text("Test Connection")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = viewModel::save,
|
||||
modifier = Modifier.weight(1f),
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(
|
||||
title: String,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(1.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style =
|
||||
MaterialTheme.typography.titleSmall.copy(
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.m57.hermescontrol.ui.settings
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class SettingsUiState(
|
||||
val host: String = "127.0.0.1",
|
||||
val port: String = "9119",
|
||||
val token: String = "",
|
||||
val autoReconnect: Boolean = true,
|
||||
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
||||
val isTesting: Boolean = false,
|
||||
val testResult: String? = null,
|
||||
val isSaved: Boolean = false,
|
||||
)
|
||||
|
||||
class SettingsViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
loadSettings()
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
host = AuthManager.getHost(),
|
||||
port = AuthManager.getPort().toString(),
|
||||
token = AuthManager.getToken() ?: "",
|
||||
autoReconnect = AuthManager.isAutoReconnect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onHostChange(value: String) {
|
||||
_uiState.update { it.copy(host = value.trim(), isSaved = false) }
|
||||
}
|
||||
|
||||
fun onPortChange(value: String) {
|
||||
_uiState.update { it.copy(port = value.filter { c -> c.isDigit() }, isSaved = false) }
|
||||
}
|
||||
|
||||
fun onTokenChange(value: String) {
|
||||
_uiState.update { it.copy(token = value.trim(), isSaved = false) }
|
||||
}
|
||||
|
||||
fun onAutoReconnectChange(enabled: Boolean) {
|
||||
_uiState.update { it.copy(autoReconnect = enabled, isSaved = false) }
|
||||
}
|
||||
|
||||
fun onThemeChange(theme: ThemePreference) {
|
||||
_uiState.update { it.copy(themePreference = theme, isSaved = false) }
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val state = _uiState.value
|
||||
val port = state.port.toIntOrNull() ?: 9119
|
||||
|
||||
AuthManager.setHost(state.host)
|
||||
AuthManager.setPort(port)
|
||||
AuthManager.setToken(state.token)
|
||||
AuthManager.setAutoReconnect(state.autoReconnect)
|
||||
ApiClient.rebuild()
|
||||
|
||||
_uiState.update { it.copy(isSaved = true, testResult = null) }
|
||||
}
|
||||
|
||||
fun testConnection() {
|
||||
val state = _uiState.value
|
||||
if (state.token.isBlank()) {
|
||||
_uiState.update { it.copy(testResult = "❌ Token is required") }
|
||||
return
|
||||
}
|
||||
|
||||
// Save first so ApiClient uses updated settings
|
||||
save()
|
||||
|
||||
_uiState.update { it.copy(isTesting = true, testResult = null) }
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getStatus()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update {
|
||||
it.copy(isTesting = false, testResult = "✅ Connected successfully")
|
||||
}
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTesting = false,
|
||||
testResult = "❌ HTTP ${response.code()}: ${response.message()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isTesting = false,
|
||||
testResult = "❌ ${e.message ?: "Connection failed"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SkillsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: SkillsViewModel = viewModel { SkillsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadSkills()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = androidx.compose.material.icons.Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Skills Management") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadSkills() }) {
|
||||
Text("Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null) {
|
||||
Text(
|
||||
text = state.errorMessage ?: "",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.skills) { skill ->
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = skill.name, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = skill.category ?: "",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = skill.description ?: "",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = skill.enabled,
|
||||
onCheckedChange = { viewModel.toggleSkill(skill) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.m57.hermescontrol.ui.skills
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.Skill
|
||||
import com.m57.hermescontrol.data.model.ToggleSkillRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class SkillsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val skills: List<Skill> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class SkillsViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(SkillsUiState())
|
||||
val uiState: StateFlow<SkillsUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadSkills() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getSkills()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update { it.copy(isLoading = false, skills = response.body().orEmpty()) }
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load skills: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load skills: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleSkill(skill: Skill) {
|
||||
val originalEnabled = skill.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
|
||||
// Optimistically update
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
skills =
|
||||
state.skills.map {
|
||||
if (it.name == skill.name) it.copy(enabled = targetEnabled) else it
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.toggleSkill(ToggleSkillRequest(skill.name, targetEnabled))
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
revertSkillToggle(skill.name, originalEnabled, "Failed to toggle skill: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertSkillToggle(
|
||||
name: String,
|
||||
originalEnabled: Boolean,
|
||||
errorMsg: String,
|
||||
) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
skills =
|
||||
state.skills.map {
|
||||
if (it.name == name) it.copy(enabled = originalEnabled) else it
|
||||
},
|
||||
toastMessage = errorMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.m57.hermescontrol.ui.toolsets
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SuggestionChip
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ToolsetsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: ToolsetsViewModel = viewModel { ToolsetsViewModel() },
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.loadToolsets()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.toastMessage) {
|
||||
state.toastMessage?.let { msg ->
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
viewModel.clearToast()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (onOpenDrawer != null) {
|
||||
IconButton(onClick = onOpenDrawer) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Menu,
|
||||
contentDescription = "Open Drawer",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = { Text("Toolsets Manager") },
|
||||
actions = {
|
||||
IconButton(onClick = { viewModel.loadToolsets() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
modifier = modifier,
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (state.isLoading && state.toolsets.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else if (state.errorMessage != null && state.toolsets.isEmpty()) {
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.Center)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = state.errorMessage ?: "", color = MaterialTheme.colorScheme.error)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
IconButton(onClick = { viewModel.loadToolsets() }) {
|
||||
Icon(imageVector = Icons.Filled.Refresh, contentDescription = "Retry")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.toolsets) { toolset ->
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (toolset.enabled) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = toolset.label ?: toolset.name.replaceFirstChar { it.uppercase() },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
toolset.description?.let {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
toolset.tools?.let { tools ->
|
||||
if (tools.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
tools.forEach { tool ->
|
||||
SuggestionChip(
|
||||
onClick = {},
|
||||
label = {
|
||||
Text(
|
||||
text = tool,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Switch(
|
||||
checked = toolset.enabled,
|
||||
onCheckedChange = { viewModel.toggleToolset(toolset) },
|
||||
modifier = Modifier.padding(start = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.m57.hermescontrol.ui.toolsets
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.model.Toolset
|
||||
import com.m57.hermescontrol.data.model.ToolsetToggleRequest
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ToolsetsUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val toolsets: List<Toolset> = emptyList(),
|
||||
val errorMessage: String? = null,
|
||||
val toastMessage: String? = null,
|
||||
)
|
||||
|
||||
class ToolsetsViewModel : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ToolsetsUiState())
|
||||
val uiState: StateFlow<ToolsetsUiState> = _uiState.asStateFlow()
|
||||
|
||||
fun loadToolsets() {
|
||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.getToolsets()
|
||||
}
|
||||
if (response.isSuccessful) {
|
||||
_uiState.update { it.copy(isLoading = false, toolsets = response.body().orEmpty()) }
|
||||
} else {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load toolsets: HTTP ${response.code()}",
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to load toolsets: ${e.message}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleToolset(toolset: Toolset) {
|
||||
val originalEnabled = toolset.enabled
|
||||
val targetEnabled = !originalEnabled
|
||||
|
||||
// Optimistic UI update
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
toolsets =
|
||||
state.toolsets.map {
|
||||
if (it.name == toolset.name) it.copy(enabled = targetEnabled) else it
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val response =
|
||||
withContext(Dispatchers.IO) {
|
||||
ApiClient.hermesApi.toggleToolset(toolset.name, ToolsetToggleRequest(targetEnabled))
|
||||
}
|
||||
if (!response.isSuccessful) {
|
||||
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: HTTP ${response.code()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
revertToggle(toolset.name, originalEnabled, "Failed to toggle toolset: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertToggle(
|
||||
name: String,
|
||||
originalEnabled: Boolean,
|
||||
errorMsg: String,
|
||||
) {
|
||||
_uiState.update { state ->
|
||||
state.copy(
|
||||
toolsets =
|
||||
state.toolsets.map {
|
||||
if (it.name == name) it.copy(enabled = originalEnabled) else it
|
||||
},
|
||||
toastMessage = errorMsg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearToast() {
|
||||
_uiState.update { it.copy(toastMessage = null) }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user