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.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.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController 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 import androidx.compose.ui.unit.sp import kotlinx.coroutines.delay 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 inputFieldVersion by remember { mutableStateOf(0) } var suppressedImeEcho by remember { mutableStateOf(null) } 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] LaunchedEffect(currentLevelIndex) { screenScrollState.scrollTo(0) } fun clearCommandInput(recreateField: Boolean = false) { commandInput = TextFieldValue(text = "", selection = TextRange.Zero) if (recreateField) { inputFieldVersion += 1 } } fun resetCurrentLevel(message: String = "Level reset.") { repo = currentLevel.setup() clearCommandInput() suppressedImeEcho = null 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 submittedText = commandInput.text val raw = submittedText.trim() if (raw.isBlank()) return suppressedImeEcho = submittedText clearCommandInput(recreateField = true) 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}") clearCommandInput() suppressedImeEcho = null hintIndex = 0 } else { repo = newRepo output = listOf("🏁 All available MVP levels completed.") clearCommandInput() suppressedImeEcho = null } } 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}") clearCommandInput() suppressedImeEcho = null 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, inputFieldVersion = inputFieldVersion, commandInput = commandInput, onValueChange = { val blockedEcho = suppressedImeEcho if (blockedEcho != null && commandInput.text.isEmpty()) { val blockedTrimmed = blockedEcho.trim() if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) { return@TerminalPanel } } suppressedImeEcho = null 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, inputFieldVersion: Int, commandInput: TextFieldValue, onValueChange: (TextFieldValue) -> Unit, onRun: () -> Unit, onTab: () -> Unit, onCursorLeft: () -> Unit, onCursorRight: () -> Unit, onHistoryUp: () -> Unit, onHistoryDown: () -> Unit, modifier: Modifier = Modifier, ) { val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current LaunchedEffect(inputFieldVersion) { if (inputFieldVersion == 0) return@LaunchedEffect delay(75) focusRequester.requestFocus() keyboardController?.show() } fun submitCommand() { focusManager.clearFocus(force = true) keyboardController?.hide() onRun() } 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 ) key(inputFieldVersion) { BasicTextField( value = commandInput, onValueChange = onValueChange, modifier = Modifier .weight(1f) .focusRequester(focusRequester), 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 = { submitCommand() }) ) } } } } } } @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 = "TAB", onClick = onTab, modifier = Modifier.width(68.dp)) TerminalKeyButton(label = "←", onClick = onCursorLeft, modifier = Modifier.width(48.dp)) TerminalKeyButton(label = "→", onClick = onCursorRight, modifier = Modifier.width(48.dp)) TerminalKeyButton(label = "↑", onClick = onHistoryUp, modifier = Modifier.width(48.dp)) TerminalKeyButton(label = "↓", onClick = onHistoryDown, modifier = Modifier.width(48.dp)) } } @Composable private fun TerminalKeyButton( label: String, onClick: () -> Unit, modifier: Modifier = Modifier, ) { Button( onClick = onClick, modifier = modifier.height(34.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), colors = ButtonDefaults.buttonColors( containerColor = PanelSecondary, contentColor = TextPrimary ) ) { Text( text = label, fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Bold, fontSize = 13.sp ) } } 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 }