460 lines
18 KiB
Kotlin
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
|
|
} |