Initial commit of HermesControl Android app

This commit is contained in:
Hy4ri committed 2026-06-16 00:00:36 +03:00
commit d6ff3de594
92 files changed
+10004

No files matched your search

@@ -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) }
}
}
}
}