Files
Githug-Android/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
Joe Tretter abf042ae65 Auto-commit after successful build: update app gameplay/UI
Changed files:\napp/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
2026-04-22 08:44:10 -05:00

460 lines
18 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.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<String>()) }
var commandHistory by remember { mutableStateOf(listOf<String>()) }
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<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>,
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>): 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
}