Skip to content

Commit 834aed3

Browse files
committed
feat(settings): agregar pantalla de diagnóstico con terminal de comandos
Agrega sección "Diagnóstico" en el menú de ajustes con un shell runner que ejecuta comandos en Dispatchers.IO y muestra el output con fuente monoespaciada. Incluye quick commands predefinidos (getprop, dumpsys, ip addr, ping) y campo de texto libre.
1 parent ff83891 commit 834aed3

1 file changed

Lines changed: 204 additions & 1 deletion

File tree

settings/src/main/kotlin/dev/helm/settings/SettingsScreen.kt

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import androidx.compose.animation.slideInHorizontally
1313
import androidx.compose.animation.slideOutHorizontally
1414
import androidx.compose.animation.togetherWith
1515
import androidx.compose.foundation.background
16+
import androidx.compose.foundation.horizontalScroll
1617
import androidx.compose.foundation.isSystemInDarkTheme
1718
import androidx.compose.foundation.layout.Arrangement
1819
import androidx.compose.foundation.layout.Box
@@ -30,16 +31,22 @@ import androidx.compose.foundation.layout.systemBarsPadding
3031
import androidx.compose.foundation.rememberScrollState
3132
import androidx.compose.foundation.shape.CircleShape
3233
import androidx.compose.foundation.shape.RoundedCornerShape
34+
import androidx.compose.foundation.text.BasicTextField
35+
import androidx.compose.foundation.text.KeyboardActions
36+
import androidx.compose.foundation.text.KeyboardOptions
3337
import androidx.compose.foundation.verticalScroll
3438
import androidx.compose.material.icons.Icons
3539
import androidx.compose.material.icons.automirrored.filled.ArrowBack
3640
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
3741
import androidx.compose.material.icons.automirrored.filled.VolumeUp
3842
import androidx.compose.material.icons.filled.BrightnessHigh
3943
import androidx.compose.material.icons.filled.Check
44+
import androidx.compose.material.icons.filled.Code
4045
import androidx.compose.material.icons.filled.Info
4146
import androidx.compose.material.icons.filled.Palette
47+
import androidx.compose.material.icons.filled.PlayArrow
4248
import androidx.compose.material.icons.filled.SystemUpdate
49+
import androidx.compose.material3.CircularProgressIndicator
4350
import androidx.compose.material3.Icon
4451
import androidx.compose.material3.IconButton
4552
import androidx.compose.material3.MaterialTheme
@@ -51,13 +58,17 @@ import androidx.compose.runtime.collectAsState
5158
import androidx.compose.runtime.getValue
5259
import androidx.compose.runtime.mutableStateOf
5360
import androidx.compose.runtime.remember
61+
import androidx.compose.runtime.rememberCoroutineScope
5462
import androidx.compose.runtime.setValue
5563
import androidx.compose.ui.Alignment
5664
import androidx.compose.ui.Modifier
5765
import androidx.compose.ui.draw.clip
5866
import androidx.compose.ui.graphics.Color
67+
import androidx.compose.ui.graphics.SolidColor
5968
import androidx.compose.ui.graphics.vector.ImageVector
6069
import androidx.compose.ui.platform.LocalContext
70+
import androidx.compose.ui.text.font.FontFamily
71+
import androidx.compose.ui.text.input.ImeAction
6172
import androidx.compose.ui.text.style.TextOverflow
6273
import androidx.compose.ui.unit.dp
6374
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -66,8 +77,11 @@ import dev.helm.core.neumorphicClickable
6677
import dev.helm.core.neumorphicShadow
6778
import dev.helm.themes.HelmThemeVariant
6879
import dev.helm.themes.ThemeDefaults
80+
import kotlinx.coroutines.Dispatchers
81+
import kotlinx.coroutines.launch
82+
import kotlinx.coroutines.withContext
6983

70-
private enum class Section { Root, Appearance, Display, Sound, About, Updates }
84+
private enum class Section { Root, Appearance, Display, Sound, About, Updates, Diagnostics }
7185

7286
@Composable
7387
fun SettingsScreen(
@@ -108,6 +122,9 @@ fun SettingsScreen(
108122
Section.Updates -> UpdatesSection(
109123
onBack = { section = Section.Root },
110124
)
125+
Section.Diagnostics -> DiagnosticsSection(
126+
onBack = { section = Section.Root },
127+
)
111128
}
112129
}
113130
}
@@ -172,6 +189,12 @@ private fun SettingsRoot(
172189
subtitle = "Versión y dispositivo",
173190
onClick = { onSection(Section.About) },
174191
)
192+
SettingsRow(
193+
icon = Icons.Filled.Code,
194+
title = "Diagnóstico",
195+
subtitle = "Terminal de comandos",
196+
onClick = { onSection(Section.Diagnostics) },
197+
)
175198
}
176199
}
177200
}
@@ -619,3 +642,183 @@ private fun SettingsSectionCard(
619642
content = content,
620643
)
621644
}
645+
646+
// ─── Diagnostics ──────────────────────────────────────────────────────────────
647+
648+
private val QUICK_CMDS = listOf(
649+
"getprop ro.product.model",
650+
"getprop ro.build.version.release",
651+
"dumpsys battery",
652+
"ip addr show wlan0",
653+
"ping -c 3 8.8.8.8",
654+
)
655+
656+
@Composable
657+
private fun DiagnosticsSection(onBack: () -> Unit) {
658+
val scope = rememberCoroutineScope()
659+
var command by remember { mutableStateOf("") }
660+
var output by remember { mutableStateOf("Listo.") }
661+
var running by remember { mutableStateOf(false) }
662+
val outputScroll = rememberScrollState()
663+
val bg = MaterialTheme.colorScheme.background
664+
665+
fun execute(cmd: String) {
666+
if (cmd.isBlank() || running) return
667+
running = true
668+
output = "$ $cmd\n\nEjecutando…"
669+
scope.launch {
670+
val result = withContext(Dispatchers.IO) {
671+
try {
672+
val proc = ProcessBuilder("sh", "-c", cmd)
673+
.redirectErrorStream(true)
674+
.start()
675+
val text = proc.inputStream.bufferedReader().readText().take(8192)
676+
proc.waitFor()
677+
"$ $cmd\n\n${text.ifBlank { "(sin salida)" }}"
678+
} catch (e: java.io.IOException) {
679+
"$ $cmd\n\nError: ${e.message}"
680+
}
681+
}
682+
output = result
683+
running = false
684+
outputScroll.animateScrollTo(0)
685+
}
686+
}
687+
688+
Column(
689+
modifier = Modifier
690+
.fillMaxSize()
691+
.background(bg)
692+
.systemBarsPadding(),
693+
) {
694+
ScreenHeader(title = "Diagnóstico", onBack = onBack)
695+
696+
Column(
697+
modifier = Modifier
698+
.fillMaxWidth()
699+
.padding(horizontal = Spacing.lg),
700+
verticalArrangement = Arrangement.spacedBy(Spacing.sm),
701+
) {
702+
Row(
703+
modifier = Modifier
704+
.fillMaxWidth()
705+
.horizontalScroll(rememberScrollState()),
706+
horizontalArrangement = Arrangement.spacedBy(Spacing.sm),
707+
) {
708+
QUICK_CMDS.forEach { cmd ->
709+
QuickCommandChip(text = cmd) {
710+
command = cmd
711+
execute(cmd)
712+
}
713+
}
714+
}
715+
716+
Row(
717+
verticalAlignment = Alignment.CenterVertically,
718+
horizontalArrangement = Arrangement.spacedBy(Spacing.sm),
719+
modifier = Modifier.fillMaxWidth(),
720+
) {
721+
BasicTextField(
722+
value = command,
723+
onValueChange = { command = it },
724+
modifier = Modifier.weight(1f),
725+
textStyle = MaterialTheme.typography.bodyMedium.copy(
726+
color = MaterialTheme.colorScheme.onBackground,
727+
fontFamily = FontFamily.Monospace,
728+
),
729+
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
730+
singleLine = true,
731+
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
732+
keyboardActions = KeyboardActions(onDone = { execute(command.trim()) }),
733+
decorationBox = { inner ->
734+
Box(
735+
modifier = Modifier
736+
.neumorphicShadow(backgroundColor = bg, cornerRadius = 12.dp, elevation = 4.dp)
737+
.background(bg, RoundedCornerShape(12.dp))
738+
.padding(horizontal = Spacing.md, vertical = Spacing.sm)
739+
.defaultMinSize(minHeight = 56.dp),
740+
contentAlignment = Alignment.CenterStart,
741+
) {
742+
if (command.isEmpty()) {
743+
Text(
744+
text = "$ comando…",
745+
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
746+
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
747+
)
748+
}
749+
inner()
750+
}
751+
},
752+
)
753+
754+
Box(
755+
modifier = Modifier
756+
.size(56.dp)
757+
.neumorphicClickable(
758+
onClick = { execute(command.trim()) },
759+
cornerRadius = 14.dp,
760+
elevation = 4.dp,
761+
),
762+
contentAlignment = Alignment.Center,
763+
) {
764+
if (running) {
765+
CircularProgressIndicator(
766+
modifier = Modifier.size(24.dp),
767+
color = MaterialTheme.colorScheme.primary,
768+
strokeWidth = 2.dp,
769+
)
770+
} else {
771+
Icon(
772+
imageVector = Icons.Filled.PlayArrow,
773+
contentDescription = "Ejecutar",
774+
tint = MaterialTheme.colorScheme.primary,
775+
modifier = Modifier.size(28.dp),
776+
)
777+
}
778+
}
779+
}
780+
}
781+
782+
Box(
783+
modifier = Modifier
784+
.weight(1f)
785+
.fillMaxWidth()
786+
.padding(horizontal = Spacing.lg, vertical = Spacing.md)
787+
.neumorphicShadow(backgroundColor = bg, cornerRadius = 16.dp, elevation = 6.dp)
788+
.background(bg, RoundedCornerShape(16.dp))
789+
.clip(RoundedCornerShape(16.dp)),
790+
) {
791+
Column(
792+
modifier = Modifier
793+
.fillMaxSize()
794+
.verticalScroll(outputScroll)
795+
.padding(Spacing.md),
796+
) {
797+
Text(
798+
text = output,
799+
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
800+
color = MaterialTheme.colorScheme.onBackground,
801+
)
802+
}
803+
}
804+
}
805+
}
806+
807+
@Composable
808+
private fun QuickCommandChip(text: String, onClick: () -> Unit) {
809+
val bg = MaterialTheme.colorScheme.background
810+
Box(
811+
modifier = Modifier
812+
.defaultMinSize(minHeight = 40.dp)
813+
.neumorphicClickable(onClick = onClick, cornerRadius = 20.dp, elevation = 3.dp)
814+
.padding(horizontal = Spacing.md, vertical = Spacing.sm),
815+
contentAlignment = Alignment.Center,
816+
) {
817+
Text(
818+
text = text,
819+
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
820+
color = MaterialTheme.colorScheme.onSurfaceVariant,
821+
maxLines = 1,
822+
)
823+
}
824+
}

0 commit comments

Comments
 (0)