diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index e8a36ea1..9b287edb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -5,55 +5,12 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.PaddingValues 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.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Backup -import androidx.compose.material.icons.filled.Build -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.CloudDownload -import androidx.compose.material.icons.filled.ContentCopy -import androidx.compose.material.icons.filled.DeleteForever -import androidx.compose.material.icons.filled.Description -import androidx.compose.material.icons.filled.Devices -import androidx.compose.material.icons.filled.HealthAndSafety -import androidx.compose.material.icons.filled.Link -import androidx.compose.material.icons.filled.Memory -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -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.Speed -import androidx.compose.material.icons.filled.Stop -import androidx.compose.material.icons.filled.Storage -import androidx.compose.material.icons.filled.Update -import androidx.compose.material.icons.filled.UploadFile -import androidx.compose.material.icons.filled.VerifiedUser +import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -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.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -63,46 +20,44 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R -import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.ui.chat.MediaImageStore import com.m57.hermescontrol.ui.common.ActionProgressDialog +import com.m57.hermescontrol.ui.common.ConfirmDialog import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold -import com.m57.hermescontrol.ui.common.InfoRow import com.m57.hermescontrol.ui.common.NavIcon -import com.m57.hermescontrol.ui.common.SectionHeader import com.m57.hermescontrol.ui.common.SkeletonListState -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.common.ToastEffect -import com.m57.hermescontrol.ui.system.components.CredentialEntryRow -import com.m57.hermescontrol.ui.system.components.HookCard -import com.m57.hermescontrol.util.formatBytes -import com.m57.hermescontrol.util.formatDuration +import com.m57.hermescontrol.ui.system.components.HookCreateDialog +import com.m57.hermescontrol.ui.system.components.actionLogSection +import com.m57.hermescontrol.ui.system.components.checkpointsSection +import com.m57.hermescontrol.ui.system.components.credentialsSection +import com.m57.hermescontrol.ui.system.components.curatorSection +import com.m57.hermescontrol.ui.system.components.gatewaySection +import com.m57.hermescontrol.ui.system.components.hostSection +import com.m57.hermescontrol.ui.system.components.operationsSection +import com.m57.hermescontrol.ui.system.components.portalSection +import com.m57.hermescontrol.ui.system.components.shellHooksSection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +/** Staged SAF-picked backup archive for the restore flow (issue #786). */ +private data class PendingImport( + val uri: Uri, + val name: String, + val mime: String, +) + @Composable fun SystemScreen( modifier: Modifier = Modifier, @@ -110,21 +65,18 @@ fun SystemScreen( viewModel: SystemViewModel? = null, ) { val app = LocalContext.current.applicationContext as Application - val viewModel = viewModel ?: viewModel { SystemViewModel(app) } - val state by viewModel.uiState.collectAsStateWithLifecycle() + val resolvedViewModel = viewModel ?: viewModel { SystemViewModel(app) } + val state by resolvedViewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current - val statusColors = LocalHermesStatusColors.current val uriHandler = LocalUriHandler.current LaunchedEffect(Unit) { - viewModel.loadAll() + resolvedViewModel.loadAll() } - ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + ToastEffect(toastMessage = state.toastMessage, onClearToast = resolvedViewModel::clearToast) // ── Backup import (issue #786) ──────────────────────────────────────── - // SAF file picker → stage uri/name/mime → confirm dialog → read bytes on - // IO → multipart upload via SystemViewModel.importArchive. val context = LocalContext.current val importScope = rememberCoroutineScope() var pendingImport by remember { mutableStateOf(null) } @@ -147,9 +99,9 @@ fun SystemScreen( meta.fold( onSuccess = { pendingImport = it - viewModel.setImportFile(it.name) + resolvedViewModel.setImportFile(it.name) }, - onFailure = { viewModel.showToast("Could not read selected file: ${it.message}") }, + onFailure = { resolvedViewModel.showToast("Could not read selected file: ${it.message}") }, ) } } @@ -158,132 +110,127 @@ fun SystemScreen( if (state.updateConfirmOpen) { AlertDialog( - onDismissRequest = viewModel::closeUpdateConfirm, + onDismissRequest = resolvedViewModel::closeUpdateConfirm, title = { Text(stringResource(R.string.system_update_confirm_title)) }, text = { Text(stringResource(R.string.system_update_confirm_desc)) }, confirmButton = { TextButton(onClick = { - viewModel.closeUpdateConfirm() - viewModel.applyUpdate() + resolvedViewModel.closeUpdateConfirm() + resolvedViewModel.applyUpdate() }) { Text(stringResource(R.string.system_confirm_update_now)) } }, dismissButton = { - TextButton(onClick = viewModel::closeUpdateConfirm) { + TextButton(onClick = resolvedViewModel::closeUpdateConfirm) { Text(stringResource(R.string.system_confirm_cancel)) } }, ) } - // Update progress popup (issue #863): replaces the old bottom-of-screen - // action log for the update flow — spinner + log tail while running, - // success/failure state once it exits. ActionProgressDialog( - controller = viewModel.actionProgress, + controller = resolvedViewModel.actionProgress, title = stringResource(R.string.system_update_progress_title), ) - // Credential remove confirmation + // Credential remove confirmation using ConfirmDialog var credToRemove by remember { mutableStateOf?>(null) } - if (credToRemove != null) { - AlertDialog( - onDismissRequest = { credToRemove = null }, - title = { Text(stringResource(R.string.system_credentials_remove_confirm_title)) }, - text = { Text(stringResource(R.string.system_credentials_remove_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - credToRemove?.let { (p, i) -> viewModel.removeCredential(p, i) } - credToRemove = null - }) { - Text(stringResource(R.string.action_delete)) - } - }, - dismissButton = { - TextButton(onClick = { credToRemove = null }) { - Text(stringResource(R.string.system_confirm_cancel)) - } + credToRemove?.let { (provider, index) -> + ConfirmDialog( + title = stringResource(R.string.system_credentials_remove_confirm_title), + message = stringResource(R.string.system_credentials_remove_confirm_desc), + onConfirm = { + resolvedViewModel.removeCredential(provider, index) + credToRemove = null }, + onDismiss = { credToRemove = null }, + confirmText = stringResource(R.string.action_delete), + isDestructive = true, + icon = Icons.Filled.Delete, ) } // Prune checkpoints confirmation var pruneConfirm by remember { mutableStateOf(false) } if (pruneConfirm) { - AlertDialog( - onDismissRequest = { pruneConfirm = false }, - title = { Text(stringResource(R.string.system_checkpoints_prune_confirm_title)) }, - text = { Text(stringResource(R.string.system_checkpoints_prune_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - pruneConfirm = false - viewModel.pruneCheckpoints() - }) { - Text(stringResource(R.string.action_ok)) - } - }, - dismissButton = { - TextButton(onClick = { pruneConfirm = false }) { - Text(stringResource(R.string.system_confirm_cancel)) - } + ConfirmDialog( + title = stringResource(R.string.system_checkpoints_prune_confirm_title), + message = stringResource(R.string.system_checkpoints_prune_confirm_desc), + onConfirm = { + pruneConfirm = false + resolvedViewModel.pruneCheckpoints() }, + onDismiss = { pruneConfirm = false }, + confirmText = stringResource(R.string.action_ok), + isDestructive = true, ) } - // Import / Restore confirmation + // Restore confirmation var importConfirm by remember { mutableStateOf(false) } if (importConfirm) { - AlertDialog( - onDismissRequest = { importConfirm = false }, - title = { Text(stringResource(R.string.system_op_import_confirm_title)) }, - text = { Text(stringResource(R.string.system_op_import_confirm_desc)) }, - confirmButton = { - TextButton(onClick = { - importConfirm = false - val pending = pendingImport - pendingImport = null - if (pending != null) { - importScope.launch { - val read = - withContext(Dispatchers.IO) { - runCatching { - context.contentResolver.openInputStream(pending.uri)?.use { it.readBytes() } - } + ConfirmDialog( + title = stringResource(R.string.system_op_import_confirm_title), + message = stringResource(R.string.system_op_import_confirm_desc), + onConfirm = { + importConfirm = false + val pending = pendingImport + pendingImport = null + if (pending != null) { + importScope.launch { + val read = + withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openInputStream(pending.uri)?.use { it.readBytes() } } - read.fold( - onSuccess = { bytes -> - if (bytes != null) { - viewModel.importArchive(pending.name, bytes, pending.mime) - } else { - viewModel.showToast("Could not read selected file") - } - }, - onFailure = { - viewModel.showToast("Could not read selected file: ${it.message}") - }, - ) - } + } + read.fold( + onSuccess = { bytes -> + if (bytes != null) { + resolvedViewModel.importArchive(pending.name, bytes, pending.mime) + } else { + resolvedViewModel.showToast("Could not read selected file") + } + }, + onFailure = { + resolvedViewModel.showToast("Could not read selected file: ${it.message}") + }, + ) } - }) { - Text(stringResource(R.string.system_confirm_restore)) - } - }, - dismissButton = { - TextButton(onClick = { importConfirm = false }) { - Text(stringResource(R.string.system_confirm_cancel)) } }, + onDismiss = { importConfirm = false }, + confirmText = stringResource(R.string.system_confirm_restore), + isDestructive = true, ) } + // Hook creation modal (hoisted out of LazyColumn item) + HookCreateDialog( + isOpen = state.hookModalOpen, + event = state.hookEvent, + command = state.hookCommand, + matcher = state.hookMatcher, + timeout = state.hookTimeout, + approve = state.hookApprove, + isCreating = state.creatingHook, + onEventChange = resolvedViewModel::updateHookEvent, + onCommandChange = resolvedViewModel::updateHookCommand, + onMatcherChange = resolvedViewModel::updateHookMatcher, + onTimeoutChange = resolvedViewModel::updateHookTimeout, + onApproveChange = resolvedViewModel::updateHookApprove, + onCreate = resolvedViewModel::createHook, + onDismiss = resolvedViewModel::toggleHookModal, + ) + HermesScaffold( modifier = modifier, title = { Text(stringResource(R.string.screen_system)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, isRefreshing = state.isLoading, - onRefresh = { viewModel.loadAll() }, - ) { + onRefresh = { resolvedViewModel.loadAll() }, + ) { padding -> when { state.isLoading && state.stats == null && state.doctorReport == null && state.status == null -> { @@ -293,7 +240,7 @@ fun SystemScreen( state.errorMessage != null -> { ErrorState( message = state.errorMessage ?: stringResource(R.string.error_unknown), - onRetry = { viewModel.loadAll() }, + onRetry = { resolvedViewModel.loadAll() }, ) } @@ -308,35 +255,34 @@ fun SystemScreen( LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = - androidx.compose.foundation.layout.PaddingValues( + PaddingValues( horizontal = spacing.md, vertical = spacing.sm, ), verticalArrangement = Arrangement.spacedBy(spacing.sm), ) { - // ── 1. Host ──────────────────────────────────────────── - hostSection(state, spacing, statusColors, viewModel) + // ── 1. Host ── + hostSection(state, spacing, resolvedViewModel) - // ── 2. Portal ────────────────────────────────────────── + // ── 2. Portal ── portalSection(state, spacing, uriHandler) - // ── 3. Curator ───────────────────────────────────────── - curatorSection(state, spacing, statusColors, viewModel) + // ── 3. Curator ── + curatorSection(state, spacing, resolvedViewModel) - // ── 4. Gateway ───────────────────────────────────────── - gatewaySection(state, spacing, statusColors, viewModel) + // ── 4. Gateway ── + gatewaySection(state, spacing, resolvedViewModel) - // ── 6. Credentials ───────────────────────────────────── - credentialsSection(state, spacing, viewModel, credToRemove, { credToRemove = it }) + // ── 6. Credentials ── + credentialsSection(state, spacing, resolvedViewModel) { credToRemove = it } - // ── 7. Operations ────────────────────────────────────── + // ── 7. Operations ── operationsSection( - state, - spacing, - viewModel, - importConfirm, - { importConfirm = it }, - { + state = state, + spacing = spacing, + viewModel = resolvedViewModel, + onImportConfirm = { importConfirm = it }, + onImportPick = { importPickerLauncher.launch( arrayOf( "application/zip", @@ -346,8 +292,8 @@ fun SystemScreen( ), ) }, - { - viewModel.downloadBackup { body, fileName -> + downloadBackup = { + resolvedViewModel.downloadBackup { body, fileName -> importScope.launch { val uri = withContext(Dispatchers.IO) { @@ -362,1351 +308,27 @@ fun SystemScreen( }.getOrNull() } if (uri != null) { - viewModel.showToast("Backup saved to Downloads") + resolvedViewModel.showToast("Backup saved to Downloads") } else { - viewModel.showToast("Could not save backup to Downloads") + resolvedViewModel.showToast("Could not save backup to Downloads") } } } }, ) - // ── 8. Checkpoints ───────────────────────────────────── - checkpointsSection(state, spacing, pruneConfirm, { pruneConfirm = it }) + // ── 8. Checkpoints ── + checkpointsSection(state, spacing) { pruneConfirm = it } - // ── 9. Shell Hooks ───────────────────────────────────── - shellHooksSection(state, spacing, viewModel) + // ── 9. Shell Hooks ── + shellHooksSection(state, spacing, resolvedViewModel) - // ── 10. Action Log ───────────────────────────────────── + // ── 10. Action Log ── if (state.activeAction != null) { - actionLogSection(state, spacing, viewModel) + actionLogSection(state, spacing, resolvedViewModel) } } } } } } - -// ── Section composables ───────────────────────────────────────────────── - -@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) -private fun LazyListScope.hostSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - 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, - ) - } - } - } - } - } -} - -private fun LazyListScope.portalSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - uriHandler: androidx.compose.ui.platform.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 ?: "", - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.curatorSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - 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), - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.gatewaySection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - statusColors: com.m57.hermescontrol.theme.HermesStatusColors, - 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, - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.credentialsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, - credToRemove: Pair?, - onRemoveRequest: (Pair) -> 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) { - androidx.compose.material3.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, - ) - } - } - } - } - } -} - -private fun LazyListScope.operationsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - viewModel: SystemViewModel, - importConfirm: Boolean, - 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 = { - // loadAll already fetches doctor report, just call it - 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) { - androidx.compose.material3.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) { - androidx.compose.material3.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)) - - androidx.compose.material3.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) { - androidx.compose.material3.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), - ) - } - } - } - } - } - } - } -} - -private fun LazyListScope.checkpointsSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.Spacing, - pruneConfirm: Boolean, - 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) - } - } - } - } - } -} - -private fun LazyListScope.shellHooksSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.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 ?: "") - }) - } - - // Hook creation modal - if (state.hookModalOpen) { - item { - AlertDialog( - onDismissRequest = { viewModel.toggleHookModal() }, - title = { Text(stringResource(R.string.system_hooks_modal_title)) }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(spacing.sm)) { - OutlinedTextField( - value = state.hookEvent, - onValueChange = viewModel::updateHookEvent, - label = { Text(stringResource(R.string.system_hooks_field_event)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookCommand, - onValueChange = viewModel::updateHookCommand, - label = { Text(stringResource(R.string.system_hooks_field_command)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookMatcher, - onValueChange = viewModel::updateHookMatcher, - label = { Text(stringResource(R.string.system_hooks_field_matcher)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = state.hookTimeout, - onValueChange = viewModel::updateHookTimeout, - label = { Text(stringResource(R.string.system_hooks_field_timeout)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - ) - Row(verticalAlignment = Alignment.CenterVertically) { - androidx.compose.material3.Switch( - checked = state.hookApprove, - onCheckedChange = viewModel::updateHookApprove, - ) - 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 = { viewModel.createHook() }, - enabled = !state.creatingHook && state.hookCommand.isNotBlank(), - ) { - if (state.creatingHook) { - androidx.compose.material3.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 = { viewModel.toggleHookModal() }) { - Text(stringResource(R.string.system_confirm_cancel)) - } - }, - ) - } - } -} - -private fun LazyListScope.actionLogSection( - state: SystemUiState, - spacing: com.m57.hermescontrol.theme.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 = androidx.compose.ui.text.font.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, - ) - } - } - } - } - } - } - } - } -} - -private val actionButtonPadding = - androidx.compose.foundation.layout - .PaddingValues(horizontal = 8.dp, vertical = 8.dp) - -@Composable -private fun ActionButtonContent( - icon: ImageVector?, - text: String, - iconTint: androidx.compose.ui.graphics.Color = androidx.compose.material3.LocalContentColor.current, - textColor: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified, - loading: Boolean = false, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.fillMaxWidth(), - ) { - if (loading) { - androidx.compose.material3.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, - ) - } -} - -/** Staged SAF-picked backup archive for the restore flow (issue #786). */ -private data class PendingImport( - val uri: Uri, - val name: String, - val mime: String, -) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt new file mode 100644 index 00000000..59cb4a6b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionButtonContent.kt @@ -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, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt new file mode 100644 index 00000000..20a58659 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ActionLogSection.kt @@ -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, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt new file mode 100644 index 00000000..2659c158 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CheckpointsSection.kt @@ -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) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt new file mode 100644 index 00000000..8ab04680 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CredentialsSection.kt @@ -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) -> 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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt new file mode 100644 index 00000000..8ea2fc39 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/CuratorSection.kt @@ -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), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt new file mode 100644 index 00000000..35262e2d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt @@ -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, + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt new file mode 100644 index 00000000..5a55d978 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HookCreateDialog.kt @@ -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)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt new file mode 100644 index 00000000..6a85336b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/HostSection.kt @@ -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, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt new file mode 100644 index 00000000..892e8082 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/OperationsSection.kt @@ -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), + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt new file mode 100644 index 00000000..37dd7720 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/PortalSection.kt @@ -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 ?: "", + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt new file mode 100644 index 00000000..66c9db3e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/ShellHooksSection.kt @@ -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 ?: "") + }) + } +}