refactor(system): decompose SystemScreen into focused section components

Extract Host, Portal, Curator, Gateway, Credentials, Operations,
Checkpoints, ShellHooks, ActionLog, and HookCreateDialog into components/.
This commit is contained in:
Hy4ri committed 2026-09-11 19:07:03 +03:00
1 parent 5ecc2e7d88
commit 35dc778702
12 files changed
+1794 -1507

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
package com.m57.hermescontrol.ui.system.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
val ActionButtonPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)
@Composable
fun ActionButtonContent(
icon: ImageVector?,
text: String,
iconTint: Color = LocalContentColor.current,
textColor: Color = Color.Unspecified,
loading: Boolean = false,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth(),
) {
if (loading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
} else if (icon != null) {
Icon(icon, contentDescription = null, modifier = Modifier.size(18.dp), tint = iconTint)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = textColor,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
@@ -0,0 +1,135 @@
package com.m57.hermescontrol.ui.system.components
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.ui.system.SystemViewModel
fun LazyListScope.actionLogSection(
state: SystemUiState,
spacing: Spacing,
viewModel: SystemViewModel,
) {
item {
SectionHeader(
title = stringResource(R.string.system_action_log_title),
trailing = {
TextButton(onClick = { viewModel.closeActionLog() }) {
Icon(Icons.Filled.Close, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(spacing.xs))
Text(stringResource(R.string.action_cancel))
}
},
)
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// Action title
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Text(
text = state.activeAction ?: "",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
val actionLog = state.actionLog
if (actionLog != null) {
if (actionLog.running == true) {
StatusBadge(
text = stringResource(R.string.system_action_log_running),
status = StatusBadgeType.INFO,
)
} else {
StatusBadge(
text = stringResource(R.string.system_action_log_done),
status = StatusBadgeType.SUCCESS,
)
actionLog.exit_code?.let { code ->
Spacer(modifier = Modifier.size(spacing.xs))
StatusBadge(
text = stringResource(R.string.system_action_log_exit, code),
status =
if (code == 0) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
)
}
}
} else {
StatusBadge(
text = stringResource(R.string.system_action_log_starting),
status = StatusBadgeType.NEUTRAL,
)
}
}
// Output lines
state.actionLog?.lines?.let { lines ->
if (lines.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(modifier = Modifier.padding(spacing.sm)) {
lines.take(20).forEach { line ->
Text(
text = line,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (lines.size > 20) {
Text(
text = stringResource(R.string.system_more_lines, lines.size - 20),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,78 @@
package com.m57.hermescontrol.ui.system.components
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
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.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.InfoRow
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.util.formatBytes
fun LazyListScope.checkpointsSection(
state: SystemUiState,
spacing: Spacing,
onPruneConfirm: (Boolean) -> Unit,
) {
state.checkpoints?.let { checkpoints ->
item {
SectionHeader(title = stringResource(R.string.system_sec_checkpoints))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
val sessionsCount = checkpoints.sessions?.size ?: 0
val totalBytes = checkpoints.total_bytes ?: 0L
InfoRow(
label = "",
value =
stringResource(
R.string.system_checkpoints_summary,
sessionsCount,
formatBytes(totalBytes),
),
)
Spacer(modifier = Modifier.height(spacing.sm))
Button(
onClick = { onPruneConfirm(true) },
enabled = sessionsCount > 0,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.fillMaxWidth(),
) {
Icon(Icons.Filled.DeleteForever, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
Text(stringResource(R.string.system_checkpoints_prune), maxLines = 1, softWrap = false)
}
}
}
}
}
}
@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.system.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.ui.system.SystemViewModel
fun LazyListScope.credentialsSection(
state: SystemUiState,
spacing: Spacing,
viewModel: SystemViewModel,
onRemoveRequest: (Pair<String, Int>) -> Unit,
) {
item {
SectionHeader(
title = stringResource(R.string.system_sec_credentials),
trailing = {
val hasCreds = state.credentials.any { it.entries?.isNotEmpty() == true }
if (hasCreds) {
Text(
text = "${state.credentials.sumOf { it.entries?.size ?: 0 }} key(s)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
)
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// Add credential form
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
OutlinedTextField(
value = state.credProvider,
onValueChange = viewModel::updateCredProvider,
label = { Text(stringResource(R.string.system_credentials_provider)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
OutlinedTextField(
value = state.credKey,
onValueChange = viewModel::updateCredKey,
label = { Text(stringResource(R.string.system_credentials_api_key)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = MaterialTheme.typography.bodySmall,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
OutlinedTextField(
value = state.credLabel,
onValueChange = viewModel::updateCredLabel,
label = { Text(stringResource(R.string.system_credentials_label)) },
singleLine = true,
modifier = Modifier.weight(1f),
textStyle = MaterialTheme.typography.bodySmall,
)
}
Spacer(modifier = Modifier.height(spacing.sm))
Button(
onClick = { viewModel.addCredential() },
enabled = !state.addingCred && state.credKey.isNotBlank(),
modifier = Modifier.fillMaxWidth(),
) {
if (state.addingCred) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.size(spacing.sm))
} else {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
}
Text(stringResource(R.string.system_credentials_add_key), maxLines = 1, softWrap = false)
}
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
// Provider list with entries
if (state.credentials.isEmpty()) {
Text(
text = stringResource(R.string.system_credentials_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = spacing.sm),
)
}
state.credentials.forEach { provider ->
Text(
text = provider.provider ?: "?",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(vertical = spacing.xs),
)
provider.entries?.forEach { entry ->
CredentialEntryRow(
entry = entry,
providerName = provider.provider ?: "",
onRemove = { onRemoveRequest(Pair(provider.provider ?: "", entry.index ?: 0)) },
spacing = spacing,
)
}
}
}
}
}
}
@@ -0,0 +1,136 @@
package com.m57.hermescontrol.ui.system.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.InfoRow
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.ui.system.SystemViewModel
fun LazyListScope.curatorSection(
state: SystemUiState,
spacing: Spacing,
viewModel: SystemViewModel,
) {
state.curator?.let { curator ->
item {
SectionHeader(title = stringResource(R.string.system_sec_curator))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// Status
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
val statusText: String
val statusType: StatusBadgeType
when {
curator.enabled != true -> {
statusText = stringResource(R.string.system_status_disabled)
statusType = StatusBadgeType.ERROR
}
curator.paused == true -> {
statusText = stringResource(R.string.system_status_paused)
statusType = StatusBadgeType.WARNING
}
else -> {
statusText = stringResource(R.string.system_status_active)
statusType = StatusBadgeType.SUCCESS
}
}
StatusBadge(text = statusText, status = statusType)
}
// Interval info
curator.interval_hours?.let { hrs ->
InfoRow(
label = stringResource(R.string.system_curator_interval, hrs),
value = "",
)
Spacer(modifier = Modifier.height(spacing.xs))
}
// Last run
curator.last_run_at?.let { lastRun ->
InfoRow(
label = stringResource(R.string.system_curator_last_run, lastRun),
value = "",
)
} ?: InfoRow(
label = stringResource(R.string.system_curator_never_run),
value = "",
)
// Actions
if (curator.enabled == true) {
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
OutlinedButton(
onClick = { viewModel.toggleCuratorPaused() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
if (curator.paused == true) {
ActionButtonContent(
icon = Icons.Filled.PlayArrow,
text = stringResource(R.string.system_curator_resume),
)
} else {
ActionButtonContent(
icon = Icons.Filled.Pause,
text = stringResource(R.string.system_curator_pause),
)
}
}
Button(
onClick = { viewModel.runCuratorNow() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Refresh,
text = stringResource(R.string.system_curator_run_now),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,187 @@
package com.m57.hermescontrol.ui.system.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.InfoRow
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.ui.system.SystemViewModel
fun LazyListScope.gatewaySection(
state: SystemUiState,
spacing: Spacing,
viewModel: SystemViewModel,
) {
state.status?.let { status ->
item {
SectionHeader(title = stringResource(R.string.system_sec_gateway))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// Status badge + version
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
val isRunning = status.gateway_running == true
StatusBadge(
text =
if (isRunning) {
stringResource(R.string.system_status_running)
} else {
stringResource(R.string.system_status_stopped)
},
status = if (isRunning) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
)
}
}
// Version · Sessions · PID
status.version?.let { ver ->
val parts = mutableListOf(ver)
status.active_sessions?.let { parts.add("$it session(s)") }
state.doctorReport?.let { report ->
if (report.ok && report.pid != null) {
parts.add("pid ${report.pid}")
}
}
InfoRow(
label = stringResource(R.string.system_label_hermes_version),
value = parts.joinToString(" · "),
)
}
// Auth required
status.auth_required?.let { auth ->
InfoRow(
label = stringResource(R.string.system_auth_required),
value = if (auth) "yes" else "no",
)
}
// Active platforms
status.gateway_platforms?.let { platforms ->
if (platforms.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
platforms.forEach { (name, platform) ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Icon(
Icons.Filled.Devices,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
platform?.state?.let { platformState ->
StatusBadge(
text = platformState,
status =
when (platformState.lowercase()) {
"connected", "running", "ready" -> StatusBadgeType.SUCCESS
"error", "disconnected", "failed" -> StatusBadgeType.ERROR
else -> StatusBadgeType.NEUTRAL
},
)
}
}
}
}
}
// Action buttons
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
val isRunning = status.gateway_running == true
if (!isRunning) {
Button(
onClick = { viewModel.startGateway() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.PlayArrow,
text = stringResource(R.string.system_gateway_start),
)
}
}
if (isRunning) {
Button(
onClick = { viewModel.restartGateway() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.RestartAlt,
text = stringResource(R.string.system_gateway_restart),
)
}
OutlinedButton(
onClick = { viewModel.stopGateway() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Stop,
text = stringResource(R.string.system_gateway_stop),
iconTint = MaterialTheme.colorScheme.error,
textColor = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,121 @@
package com.m57.hermescontrol.ui.system.components
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.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalSpacing
@Composable
fun HookCreateDialog(
isOpen: Boolean,
event: String,
command: String,
matcher: String,
timeout: String,
approve: Boolean,
isCreating: Boolean,
onEventChange: (String) -> Unit,
onCommandChange: (String) -> Unit,
onMatcherChange: (String) -> Unit,
onTimeoutChange: (String) -> Unit,
onApproveChange: (Boolean) -> Unit,
onCreate: () -> Unit,
onDismiss: () -> Unit,
) {
if (!isOpen) return
val spacing = LocalSpacing.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.system_hooks_modal_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) {
OutlinedTextField(
value = event,
onValueChange = onEventChange,
label = { Text(stringResource(R.string.system_hooks_field_event)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = command,
onValueChange = onCommandChange,
label = { Text(stringResource(R.string.system_hooks_field_command)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = matcher,
onValueChange = onMatcherChange,
label = { Text(stringResource(R.string.system_hooks_field_matcher)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = timeout,
onValueChange = onTimeoutChange,
label = { Text(stringResource(R.string.system_hooks_field_timeout)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = approve,
onCheckedChange = onApproveChange,
)
Spacer(modifier = Modifier.width(spacing.sm))
Text(
text = stringResource(R.string.system_hooks_field_approve),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = stringResource(R.string.system_hooks_warning),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
},
confirmButton = {
TextButton(
onClick = onCreate,
enabled = !isCreating && command.isNotBlank(),
) {
if (isCreating) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.size(spacing.sm))
}
Text(stringResource(R.string.system_hooks_create))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.system_confirm_cancel))
}
},
)
}
@@ -0,0 +1,234 @@
package com.m57.hermescontrol.ui.system.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Memory
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.InfoRow
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatCard
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.ui.system.SystemViewModel
import com.m57.hermescontrol.util.formatBytes
import com.m57.hermescontrol.util.formatDuration
@OptIn(ExperimentalLayoutApi::class)
fun LazyListScope.hostSection(
state: SystemUiState,
spacing: Spacing,
viewModel: SystemViewModel,
) {
state.stats?.let { stats ->
item {
SectionHeader(title = stringResource(R.string.system_sec_host))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// OS, Arch, Hostname
stats.os?.let { InfoRow(stringResource(R.string.system_label_os), it) }
stats.arch?.let { InfoRow(stringResource(R.string.system_label_arch), it) }
stats.hostname?.let { InfoRow(stringResource(R.string.system_label_hostname), it) }
stats.python_version?.let { python ->
val impl = stats.python_impl?.let { " ($it)" } ?: ""
InfoRow(stringResource(R.string.system_label_python), "$python$impl")
}
// Hermes version + update badge
stats.hermes_version?.let { ver ->
val updateBadge =
state.updateInfo?.let { info ->
when {
info.update_available == true && info.behind != null && info.behind > 0 -> {
" (${stringResource(
R.string.system_version_update_available,
)}: ${stringResource(R.string.system_version_behind, info.behind)})"
}
info.update_available == true -> {
" (${stringResource(
R.string.system_version_update_available,
)})"
}
else -> {
" (${stringResource(R.string.system_version_latest)})"
}
}
} ?: ""
InfoRow(stringResource(R.string.system_label_hermes_version), "$ver$updateBadge")
}
// Update buttons
Row(
modifier = Modifier.fillMaxWidth().padding(top = spacing.sm),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
OutlinedButton(
onClick = { viewModel.checkForUpdate(false) },
enabled = !state.checkingUpdate,
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
if (state.checkingUpdate) {
ActionButtonContent(
icon = null,
text = stringResource(R.string.system_action_check_updates),
loading = true,
)
} else {
ActionButtonContent(
icon = Icons.Filled.Update,
text = stringResource(R.string.system_action_check_updates),
)
}
}
state.updateInfo?.let { info ->
if (info.update_available == true && info.can_apply == true) {
Button(
onClick = { viewModel.openUpdateConfirm() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.PlayArrow,
text = stringResource(R.string.system_action_update_now),
)
}
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
// CPU / Memory / Disk stat cards
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
stats.cpu_percent?.let { cpu ->
StatCard(
label = stringResource(R.string.system_label_cpu),
value = "${cpu.toInt()}%",
icon = Icons.Filled.Speed,
modifier = Modifier.weight(1f),
)
}
stats.memory?.percent?.let { mem ->
StatCard(
label = stringResource(R.string.system_label_memory),
value = "${mem.toInt()}%",
icon = Icons.Filled.Memory,
modifier = Modifier.weight(1f),
)
}
stats.disk?.percent?.let { disk ->
StatCard(
label = stringResource(R.string.system_label_disk),
value = "${disk.toInt()}%",
icon = Icons.Filled.Storage,
modifier = Modifier.weight(1f),
)
}
}
// Memory details
stats.memory?.let { mem ->
InfoRow(
label = stringResource(R.string.system_label_memory),
value =
stringResource(
R.string.system_label_used_total_percent,
formatBytes(mem.used ?: 0L),
formatBytes(mem.total ?: 0L),
"${(mem.percent ?: 0.0).toInt()}",
),
)
}
// Disk details
stats.disk?.let { disk ->
InfoRow(
label = stringResource(R.string.system_label_disk),
value =
stringResource(
R.string.system_label_used_total_percent,
formatBytes(disk.used ?: 0L),
formatBytes(disk.total ?: 0L),
"${(disk.percent ?: 0.0).toInt()}",
),
)
}
// Uptime
stats.uptime_seconds?.let { secs ->
InfoRow(
label = stringResource(R.string.system_label_uptime),
value = formatDuration(secs),
)
}
// Load avg
stats.load_avg?.let { loads ->
if (loads.isNotEmpty()) {
val coresText =
stats.cpu_count?.let {
" ($it ${stringResource(
R.string.system_label_cores,
)})"
} ?: ""
InfoRow(
label = stringResource(R.string.system_label_load_avg),
value = loads.joinToString(", ") { "%.2f".format(it) } + coresText,
)
}
} ?: stats.cpu_count?.let { cores ->
InfoRow(
label = stringResource(R.string.system_label_cpu),
value = "$cores ${stringResource(R.string.system_label_cores)}",
)
}
// psutil warning
if (stats.psutil == false) {
Spacer(modifier = Modifier.height(spacing.sm))
StatusBadge(
text = stringResource(R.string.system_psutil_warning),
status = StatusBadgeType.WARNING,
)
}
}
}
}
}
}
@@ -0,0 +1,392 @@
package com.m57.hermescontrol.ui.system.components
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backup
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.HealthAndSafety
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material.icons.filled.VerifiedUser
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.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.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.ui.system.SystemViewModel
fun LazyListScope.operationsSection(
state: SystemUiState,
spacing: Spacing,
viewModel: SystemViewModel,
onImportConfirm: (Boolean) -> Unit,
onImportPick: () -> Unit,
downloadBackup: () -> Unit,
) {
item {
SectionHeader(title = stringResource(R.string.system_sec_operations))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// Operation buttons row 1
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
FilledTonalButton(
onClick = { viewModel.loadAll() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.HealthAndSafety,
text = stringResource(R.string.system_op_doctor),
)
}
FilledTonalButton(
onClick = { viewModel.runSecurityAudit() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.VerifiedUser,
text = stringResource(R.string.system_op_security_audit),
)
}
FilledTonalButton(
onClick = { viewModel.runUpdateSkills() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Refresh,
text = stringResource(R.string.system_op_update_skills),
)
}
}
Spacer(modifier = Modifier.height(spacing.sm))
// Operation buttons row 2
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
FilledTonalButton(
onClick = { viewModel.runPromptSize() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.HealthAndSafety,
text = stringResource(R.string.system_op_prompt_size),
)
}
FilledTonalButton(
onClick = { viewModel.runDump() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Build,
text = stringResource(R.string.system_op_dump),
)
}
FilledTonalButton(
onClick = { viewModel.runConfigMigrate() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Build,
text = stringResource(R.string.system_op_config_migrate),
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
// Backup section
Text(
text = stringResource(R.string.system_sec_backup),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
Button(
onClick = { viewModel.triggerBackup() },
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
ActionButtonContent(
icon = Icons.Filled.Backup,
text = stringResource(R.string.system_op_backup_create),
)
}
OutlinedButton(
onClick = downloadBackup,
enabled = state.backupArchive != null && !state.isDownloading,
modifier = Modifier.weight(1f),
contentPadding = ActionButtonPadding,
) {
if (state.isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.size(spacing.xs))
} else {
Icon(
Icons.Filled.CloudDownload,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.size(spacing.xs))
}
Text(
text = stringResource(R.string.system_op_backup_download),
maxLines = 1,
softWrap = false,
)
}
}
// Restore from picked archive (issue #786: SAF picker → upload)
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.system_sec_restore),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(spacing.sm))
OutlinedButton(
onClick = onImportPick,
enabled = !state.isImporting,
modifier = Modifier.fillMaxWidth(),
contentPadding = ActionButtonPadding,
) {
Icon(Icons.Filled.UploadFile, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
Text(
text = stringResource(R.string.system_op_restore_pick),
maxLines = 1,
softWrap = false,
)
}
state.importFileName?.let { name ->
Spacer(modifier = Modifier.height(spacing.sm))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.xs),
) {
Icon(
imageVector = Icons.Filled.Description,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.height(spacing.sm))
Button(
onClick = { onImportConfirm(true) },
enabled = state.importFileName != null && !state.isImporting,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
modifier = Modifier.fillMaxWidth(),
) {
if (state.isImporting) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onError,
)
Spacer(modifier = Modifier.size(spacing.xs))
} else {
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
}
Text(stringResource(R.string.system_op_restore_upload), maxLines = 1, softWrap = false)
}
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
// Debug share section
SectionHeader(title = stringResource(R.string.system_sec_debug_share))
Spacer(modifier = Modifier.height(spacing.sm))
Text(
text = stringResource(R.string.system_debug_share_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(spacing.sm))
Switch(
checked = state.shareRedact,
onCheckedChange = { viewModel.toggleShareRedact() },
)
Spacer(modifier = Modifier.size(spacing.sm))
Text(
text = stringResource(R.string.system_debug_share_redact),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = spacing.xs),
)
Spacer(modifier = Modifier.height(spacing.sm))
Button(
onClick = { viewModel.runDebugShare() },
enabled = !state.sharing,
modifier = Modifier.fillMaxWidth(),
) {
if (state.sharing) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.size(spacing.sm))
} else {
Icon(Icons.Filled.Share, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.size(spacing.xs))
}
Text(
if (state.sharing) {
stringResource(R.string.system_debug_share_uploading)
} else {
stringResource(R.string.system_debug_share_generate)
},
maxLines = 1,
softWrap = false,
)
}
// Debug share results
state.debugShare?.let { share ->
Spacer(modifier = Modifier.height(spacing.sm))
@Suppress("DEPRECATION")
val clipboardManager = LocalClipboardManager.current
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
StatusBadge(
text =
if (share.ok == true) {
stringResource(R.string.system_debug_share_uploaded)
} else {
stringResource(R.string.system_debug_share_failed)
},
status = if (share.ok == true) StatusBadgeType.SUCCESS else StatusBadgeType.ERROR,
)
share.redacted?.let { redacted ->
StatusBadge(
text =
if (redacted) {
stringResource(R.string.system_debug_share_redacted)
} else {
stringResource(R.string.system_debug_share_not_redacted)
},
status = StatusBadgeType.INFO,
)
}
share.auto_delete_seconds?.let { secs ->
StatusBadge(
text = stringResource(R.string.system_debug_share_auto_delete, secs / 3600),
status = StatusBadgeType.NEUTRAL,
)
}
}
share.urls?.forEach { (name, url) ->
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = spacing.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$name: $url",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
IconButton(onClick = {
@Suppress("DEPRECATION")
clipboardManager.setText(AnnotatedString(url))
}) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.system_debug_share_copy_all),
modifier = Modifier.size(16.dp),
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,115 @@
package com.m57.hermescontrol.ui.system.components
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.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Link
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.InfoRow
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.system.SystemUiState
fun LazyListScope.portalSection(
state: SystemUiState,
spacing: Spacing,
uriHandler: UriHandler,
) {
state.portal?.let { portal ->
item {
SectionHeader(title = stringResource(R.string.system_sec_portal))
}
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
) {
Column(modifier = Modifier.padding(spacing.md)) {
// Login status
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
) {
val loggedIn = portal.logged_in == true
StatusBadge(
text =
if (loggedIn) {
stringResource(R.string.system_status_logged_in)
} else {
stringResource(R.string.system_status_not_logged_in)
},
status = if (loggedIn) StatusBadgeType.SUCCESS else StatusBadgeType.WARNING,
)
if (!loggedIn) {
Text(
text = stringResource(R.string.system_portal_login_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Provider
portal.provider?.let { provider ->
InfoRow(
label = stringResource(R.string.system_portal_provider, provider),
value = "",
)
}
// Manage subscription link
portal.subscription_url?.let { url ->
TextButton(
onClick = { uriHandler.openUri(url) },
modifier = Modifier.padding(top = spacing.xs),
) {
Icon(Icons.Filled.Link, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(spacing.xs))
Text(
stringResource(R.string.system_portal_manage_subscription),
maxLines = 1,
softWrap = false,
)
}
}
// Feature routing
portal.features?.let { features ->
if (features.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = spacing.sm))
features.forEach { feature ->
InfoRow(
label = feature.label ?: stringResource(R.string.system_portal_feature_routing),
value = feature.state ?: "",
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,60 @@
package com.m57.hermescontrol.ui.system.components
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.Spacing
import com.m57.hermescontrol.ui.common.SectionHeader
import com.m57.hermescontrol.ui.system.SystemUiState
import com.m57.hermescontrol.ui.system.SystemViewModel
fun LazyListScope.shellHooksSection(
state: SystemUiState,
spacing: Spacing,
viewModel: SystemViewModel,
) {
val hooks = state.hooks?.hooks ?: emptyList()
item {
SectionHeader(
title = stringResource(R.string.system_sec_hooks),
trailing = {
FilledTonalButton(onClick = { viewModel.toggleHookModal() }) {
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.size(spacing.xs))
Text(stringResource(R.string.system_hooks_new), maxLines = 1, softWrap = false)
}
},
)
}
if (hooks.isEmpty()) {
item {
Text(
text = stringResource(R.string.system_hooks_empty),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = spacing.md, vertical = spacing.sm),
)
}
}
items(hooks, key = { "${it.event}:${it.command}" }) { hook ->
HookCard(hook = hook, spacing = spacing, onDelete = {
viewModel.deleteHook(hook.event ?: "", hook.command ?: "")
})
}
}