package com.kawomi.githugandroid import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp private val AppBackground = Color(0xFF000000) private val PanelPrimary = Color(0xFF121212) private val PanelSecondary = Color(0xFF1E1E1E) private val PanelTertiary = Color(0xFF262626) private val TerminalBackground = Color(0xFF050505) private val TextPrimary = Color(0xFFFFFFFF) private val TextSecondary = Color(0xFFE6E6E6) private val TextMuted = Color(0xFFBDBDBD) private val Accent = Color(0xFF00E5FF) private val Success = Color(0xFF00FF95) private val GitHugColorScheme = darkColorScheme( primary = Accent, onPrimary = Color.Black, secondary = TextPrimary, onSecondary = Color.Black, background = AppBackground, onBackground = TextPrimary, surface = PanelPrimary, onSurface = TextPrimary, surfaceVariant = PanelSecondary, onSurfaceVariant = TextSecondary, outline = TextMuted, ) @Composable fun GitHugApp() { MaterialTheme(colorScheme = GitHugColorScheme) { val levels = remember { sampleLevels() } val screenScrollState = rememberScrollState() var currentLevelIndex by remember { mutableStateOf(0) } var mode by remember { mutableStateOf(PlayMode.CLI_ONLY) } var repo by remember { mutableStateOf(levels.first().setup()) } var commandInput by remember { mutableStateOf(TextFieldValue("")) } var output by remember { mutableStateOf(listOf("Welcome to GitHug Android.")) } var hintIndex by remember { mutableStateOf(0) } var completedLevels by remember { mutableStateOf(setOf()) } var commandHistory by remember { mutableStateOf(listOf()) } var historyIndex by remember { mutableStateOf(-1) } var historyDraft by remember { mutableStateOf("") } val currentLevel = levels[currentLevelIndex] val solved = currentLevel.validator(repo) LaunchedEffect(currentLevelIndex) { screenScrollState.scrollTo(0) } fun resetCurrentLevel(message: String = "Level reset.") { repo = currentLevel.setup() commandInput = TextFieldValue("") output = listOf(message) hintIndex = 0 historyIndex = -1 historyDraft = "" } fun setCommandText(text: String) { commandInput = TextFieldValue(text = text, selection = TextRange(text.length)) } fun moveCursor(delta: Int) { val next = (commandInput.selection.start + delta).coerceIn(0, commandInput.text.length) commandInput = commandInput.copy(selection = TextRange(next)) } fun historyUp() { if (commandHistory.isEmpty()) return if (historyIndex == -1) { historyDraft = commandInput.text historyIndex = commandHistory.lastIndex } else { historyIndex = (historyIndex - 1).coerceAtLeast(0) } setCommandText(commandHistory[historyIndex]) } fun historyDown() { if (commandHistory.isEmpty() || historyIndex == -1) return if (historyIndex >= commandHistory.lastIndex) { historyIndex = -1 setCommandText(historyDraft) } else { historyIndex += 1 setCommandText(commandHistory[historyIndex]) } } fun tabComplete() { val cursor = commandInput.selection.start.coerceIn(0, commandInput.text.length) val beforeCursor = commandInput.text.substring(0, cursor) val tokenStart = beforeCursor.lastIndexOf(' ').let { if (it == -1) 0 else it + 1 } val token = commandInput.text.substring(tokenStart, cursor) if (token.isBlank()) return val matches = repo.files.map { it.name }.sorted().filter { it.startsWith(token) } if (matches.isEmpty()) return val replacement = if (matches.size == 1) matches.first() else commonPrefix(matches) if (replacement == token && matches.size > 1) { output = output + "completion> ${matches.joinToString(" ")}" return } val newText = commandInput.text.replaceRange(tokenStart, cursor, replacement) val newCursor = tokenStart + replacement.length commandInput = TextFieldValue(newText, selection = TextRange(newCursor)) } fun runCommand() { val raw = commandInput.text.trim() if (raw.isBlank()) return commandInput = TextFieldValue("") if (commandHistory.lastOrNull() != raw) { commandHistory = commandHistory + raw } historyIndex = -1 historyDraft = "" val (newRepo, lines) = GitSandboxEngine.execute(repo, raw) val solvedAfterCommand = currentLevel.validator(newRepo) val wasAlreadyCompleted = currentLevel.id in completedLevels val newOutput = buildList { addAll(output) add("$ $raw") addAll(lines) if (solvedAfterCommand && !wasAlreadyCompleted) { add("✔ Level solved: ${currentLevel.title}") } } if (solvedAfterCommand && !wasAlreadyCompleted) { completedLevels = completedLevels + currentLevel.id val hasNextLevel = currentLevelIndex < levels.lastIndex if (hasNextLevel) { val nextLevelIndex = currentLevelIndex + 1 val nextLevel = levels[nextLevelIndex] currentLevelIndex = nextLevelIndex repo = nextLevel.setup() output = listOf("Loaded level: ${nextLevel.title}") commandInput = TextFieldValue("") hintIndex = 0 } else { repo = newRepo output = listOf("🏁 All available MVP levels completed.") commandInput = TextFieldValue("") } } else { repo = newRepo output = newOutput } } Scaffold { padding -> Surface( modifier = Modifier .fillMaxSize() .padding(padding) ) { Column( modifier = Modifier .fillMaxSize() .background(AppBackground) .verticalScroll(screenScrollState) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Header(levels, currentLevelIndex, completedLevels) { index -> currentLevelIndex = index repo = levels[index].setup() output = listOf("Loaded level: ${levels[index].title}") commandInput = TextFieldValue("") hintIndex = 0 historyIndex = -1 historyDraft = "" } ModeBar(mode = mode, onModeSelected = { mode = it }) LevelCard( level = currentLevel, onHint = { val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level." output = output + "hint> $hint" hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size) }, onReset = { resetCurrentLevel() }, ) if (mode == PlayMode.VISUAL) { VisualizationPanel(repo) } TerminalPanel( output = output, commandInput = commandInput, onValueChange = { commandInput = it }, onRun = { runCommand() }, onTab = { tabComplete() }, onCursorLeft = { moveCursor(-1) }, onCursorRight = { moveCursor(1) }, onHistoryUp = { historyUp() }, onHistoryDown = { historyDown() }, modifier = Modifier .fillMaxWidth() .heightIn(min = 260.dp) ) } } } } } @Composable private fun Header(levels: List, currentLevelIndex: Int, completedLevels: Set, onSelect: (Int) -> Unit) { Card(colors = CardDefaults.cardColors(containerColor = PanelPrimary)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary) Text("CLI-first Git learning on Android, with optional repository visualization.", color = TextSecondary) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { levels.forEachIndexed { index, level -> TextButton( onClick = { onSelect(index) }, colors = ButtonDefaults.textButtonColors( contentColor = if (index == currentLevelIndex) Accent else TextSecondary ) ) { Text( text = buildString { append(if (level.id in completedLevels) "✓ " else "• ") append(level.title) } ) } } } } } } @Composable private fun ModeBar( mode: PlayMode, onModeSelected: (PlayMode) -> Unit, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { PlayMode.entries.forEach { FilterChip( selected = mode == it, onClick = { onModeSelected(it) }, label = { Text(it.label) }, colors = FilterChipDefaults.filterChipColors( selectedContainerColor = Accent, selectedLabelColor = Color.Black, containerColor = PanelSecondary, labelColor = TextPrimary ) ) } } } @Composable private fun LevelCard(level: Level, onHint: () -> Unit, onReset: () -> Unit) { Card(colors = CardDefaults.cardColors(containerColor = PanelSecondary)) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary) Text(level.description, color = TextSecondary) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button( onClick = onHint, colors = ButtonDefaults.buttonColors( containerColor = Accent, contentColor = Color.Black ) ) { Text("Hint") } TextButton( onClick = onReset, colors = ButtonDefaults.textButtonColors(contentColor = Accent) ) { Text("Reset level") } } } } } @Composable private fun VisualizationPanel(repo: RepoState) { Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { InfoCard("Workspace", repo.files.joinToString("\n") { "${if (it.staged) "[staged]" else "[file] "} ${it.name}" }.ifBlank { "(empty)" }, Modifier.weight(1f)) InfoCard("Branches", buildString { appendLine("HEAD -> ${repo.headBranch}") repo.branches.forEach { (name, _) -> appendLine(name) } }.trim(), Modifier.weight(1f)) InfoCard("Commits", repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, Modifier.weight(1f)) } } @Composable private fun InfoCard(title: String, content: String, modifier: Modifier = Modifier) { Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = PanelSecondary), shape = RoundedCornerShape(16.dp)) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text(title, color = TextPrimary, fontWeight = FontWeight.Bold) Text(content, color = TextSecondary, fontFamily = FontFamily.Monospace) } } } @Composable private fun TerminalPanel( output: List, commandInput: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, onRun: () -> Unit, onTab: () -> Unit, onCursorLeft: () -> Unit, onCursorRight: () -> Unit, onHistoryUp: () -> Unit, onHistoryDown: () -> Unit, modifier: Modifier = Modifier, ) { Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = TerminalBackground)) { Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text("Terminal", color = TextPrimary, fontWeight = FontWeight.Bold) output.forEach { line -> Text( text = line, color = if (line.startsWith("✔") || line.startsWith("🏁")) Success else TextSecondary, fontFamily = FontFamily.Monospace ) } SpecialKeyBar( onTab = onTab, onCursorLeft = onCursorLeft, onCursorRight = onCursorRight, onHistoryUp = onHistoryUp, onHistoryDown = onHistoryDown, ) Row( modifier = Modifier .fillMaxWidth() .background(PanelPrimary, RoundedCornerShape(8.dp)) .padding(horizontal = 12.dp, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = "$", color = Accent, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold ) BasicTextField( value = commandInput, onValueChange = onValueChange, modifier = Modifier.fillMaxWidth(), singleLine = true, textStyle = TextStyle(color = TextPrimary, fontFamily = FontFamily.Monospace), cursorBrush = SolidColor(Accent), keyboardOptions = KeyboardOptions( autoCorrect = false, keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Done, ), keyboardActions = KeyboardActions(onDone = { onRun() }) ) } } } } } @Composable private fun SpecialKeyBar( onTab: () -> Unit, onCursorLeft: () -> Unit, onCursorRight: () -> Unit, onHistoryUp: () -> Unit, onHistoryDown: () -> Unit, ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { TerminalKeyButton(label = "⇥", onClick = onTab, modifier = Modifier.weight(1.3f)) TerminalKeyButton(label = "←", onClick = onCursorLeft) TerminalKeyButton(label = "→", onClick = onCursorRight) TerminalKeyButton(label = "↑", onClick = onHistoryUp) TerminalKeyButton(label = "↓", onClick = onHistoryDown) } } @Composable private fun TerminalKeyButton( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Button( onClick = onClick, modifier = modifier, colors = ButtonDefaults.buttonColors( containerColor = PanelSecondary, contentColor = TextPrimary ) ) { Text(label, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold) } } private fun commonPrefix(values: List): String { if (values.isEmpty()) return "" var prefix = values.first() values.drop(1).forEach { value -> while (!value.startsWith(prefix) && prefix.isNotEmpty()) { prefix = prefix.dropLast(1) } } return prefix }