Files
Githug-Android/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
Joe Tretter 75462e784b Auto-commit after successful build: update app gameplay/UI, improve build setup
Changed files:\napp/build.gradle.kts
app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
2026-04-22 13:13:38 -05:00

517 lines
21 KiB
Kotlin

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<String?>(null) }
var output by remember { mutableStateOf(listOf("Welcome to GitHug Android.")) }
var hintIndex by remember { mutableStateOf(0) }
var completedLevels by remember { mutableStateOf(setOf<String>()) }
var commandHistory by remember { mutableStateOf(listOf<String>()) }
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<Level>, currentLevelIndex: Int, completedLevels: Set<String>, 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<String>,
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>): 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
}