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
app/src/main/res/drawable/githug_android_logo.png
artwork/GithugAndroidLogo.png
This commit is contained in:
Joe Tretter
2026-04-23 08:02:19 -05:00
parent 600f1b275c
commit 8020313e8a
4 changed files with 692 additions and 196 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "com.kawomi.githugandroid" applicationId = "com.kawomi.githugandroid"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 16 versionCode = 17
versionName = "0.1.15" versionName = "0.1.16"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true vectorDrawables.useSupportLibrary = true

View File

@@ -1,7 +1,26 @@
package com.kawomi.githugandroid package com.kawomi.githugandroid
import android.content.Context
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
@@ -12,39 +31,51 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.key import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
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.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.dp
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.IOException
private val AppBackground = Color(0xFF000000) private val AppBackground = Color(0xFF000000)
private val PanelPrimary = Color(0xFF121212) private val PanelPrimary = Color(0xFF121212)
@@ -71,15 +102,102 @@ private val GitHugColorScheme = darkColorScheme(
outline = TextMuted, outline = TextMuted,
) )
private enum class PaneId(val title: String) {
LEVELS("Levels"),
VISUAL("Visual"),
EXERCISE("Exercise"),
TERMINAL("Terminal"),
}
private data class PaneLayout(
val order: List<PaneId>,
val collapsed: Set<PaneId>,
val weights: Map<PaneId, Float>,
)
private fun defaultPaneLayout(): PaneLayout = PaneLayout(
order = listOf(PaneId.LEVELS, PaneId.VISUAL, PaneId.EXERCISE, PaneId.TERMINAL),
collapsed = emptySet(),
weights = mapOf(
PaneId.LEVELS to 1.1f,
PaneId.VISUAL to 1.3f,
PaneId.EXERCISE to 1.0f,
PaneId.TERMINAL to 1.8f,
),
)
private class PaneLayoutStore(private val context: Context) {
private val dataStore = PreferenceDataStoreFactory.create(
produceFile = { context.preferencesDataStoreFile("pane_layout_preferences") }
)
private val orderKey = stringPreferencesKey("pane_order")
private val collapsedKey = stringPreferencesKey("pane_collapsed")
private val weightsKey = stringPreferencesKey("pane_weights")
val layoutFlow = dataStore.data
.catch { error ->
if (error is IOException) emit(emptyPreferences()) else throw error
}
.map { preferences ->
val defaults = defaultPaneLayout()
val order = preferences[orderKey]
?.split(',')
?.mapNotNull { value -> PaneId.entries.firstOrNull { it.name == value } }
?.let { parsed ->
val missing = PaneId.entries.filterNot { it in parsed }
parsed + missing
}
?.takeIf { it.isNotEmpty() }
?: defaults.order
val collapsed = preferences[collapsedKey]
?.split(',')
?.mapNotNull { value -> PaneId.entries.firstOrNull { it.name == value } }
?.toSet()
?: emptySet()
val parsedWeights = preferences[weightsKey]
?.split(';')
?.mapNotNull { item ->
val parts = item.split(':', limit = 2)
val paneId = PaneId.entries.firstOrNull { it.name == parts.getOrNull(0) }
val weight = parts.getOrNull(1)?.toFloatOrNull()
if (paneId != null && weight != null) paneId to weight.coerceAtLeast(0.6f) else null
}
?.toMap()
.orEmpty()
PaneLayout(
order = order,
collapsed = collapsed,
weights = PaneId.entries.associateWith { pane -> parsedWeights[pane] ?: defaults.weights.getValue(pane) },
)
}
suspend fun save(layout: PaneLayout) {
dataStore.edit { preferences ->
preferences[orderKey] = layout.order.joinToString(",") { it.name }
preferences[collapsedKey] = layout.collapsed.joinToString(",") { it.name }
preferences[weightsKey] = layout.order.joinToString(";") { pane ->
"${pane.name}:${layout.weights[pane] ?: 1f}"
}
}
}
}
@Composable @Composable
fun GitHugApp() { fun GitHugApp() {
MaterialTheme(colorScheme = GitHugColorScheme) { MaterialTheme(colorScheme = GitHugColorScheme) {
val context = LocalContext.current val context = LocalContext.current
val levels = remember { sampleLevels() } val levels = remember { sampleLevels() }
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) } val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
val screenScrollState = rememberScrollState() val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) }
val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout())
val scope = rememberCoroutineScope()
var paneLayout by remember { mutableStateOf(defaultPaneLayout()) }
var currentLevelIndex by remember { mutableStateOf(0) } var currentLevelIndex by remember { mutableStateOf(0) }
var mode by remember { mutableStateOf(PlayMode.CLI_ONLY) }
var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) } var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) }
var commandInput by remember { mutableStateOf(TextFieldValue("")) } var commandInput by remember { mutableStateOf(TextFieldValue("")) }
var inputFieldVersion by remember { mutableStateOf(0) } var inputFieldVersion by remember { mutableStateOf(0) }
@@ -92,8 +210,14 @@ fun GitHugApp() {
var historyDraft by remember { mutableStateOf("") } var historyDraft by remember { mutableStateOf("") }
val currentLevel = levels[currentLevelIndex] val currentLevel = levels[currentLevelIndex]
LaunchedEffect(currentLevelIndex) { LaunchedEffect(persistedPaneLayout) {
screenScrollState.scrollTo(0) paneLayout = persistedPaneLayout
}
fun updatePaneLayout(transform: (PaneLayout) -> PaneLayout) {
val updated = transform(paneLayout)
paneLayout = updated
scope.launch { paneLayoutStore.save(updated) }
} }
fun clearCommandInput(recreateField: Boolean = false) { fun clearCommandInput(recreateField: Boolean = false) {
@@ -113,6 +237,17 @@ fun GitHugApp() {
historyDraft = "" historyDraft = ""
} }
fun loadLevel(index: Int) {
currentLevelIndex = index
repo = runtime.prepareLevel(levels[index])
output = listOf("Loaded level: ${levels[index].title}")
clearCommandInput()
suppressedImeEcho = null
hintIndex = 0
historyIndex = -1
historyDraft = ""
}
fun setCommandText(text: String) { fun setCommandText(text: String) {
commandInput = TextFieldValue(text = text, selection = TextRange(text.length)) commandInput = TextFieldValue(text = text, selection = TextRange(text.length))
} }
@@ -194,14 +329,7 @@ fun GitHugApp() {
completedLevels = completedLevels + currentLevel.id completedLevels = completedLevels + currentLevel.id
val hasNextLevel = currentLevelIndex < levels.lastIndex val hasNextLevel = currentLevelIndex < levels.lastIndex
if (hasNextLevel) { if (hasNextLevel) {
val nextLevelIndex = currentLevelIndex + 1 loadLevel(currentLevelIndex + 1)
val nextLevel = levels[nextLevelIndex]
currentLevelIndex = nextLevelIndex
repo = runtime.prepareLevel(nextLevel)
output = listOf("Loaded level: ${nextLevel.title}")
clearCommandInput()
suppressedImeEcho = null
hintIndex = 0
} else { } else {
repo = newRepo repo = newRepo
output = listOf("🏁 All available MVP levels completed.") output = listOf("🏁 All available MVP levels completed.")
@@ -218,32 +346,42 @@ fun GitHugApp() {
output = output + runtime.commandReferenceLines() output = output + runtime.commandReferenceLines()
} }
Scaffold { padding -> Scaffold(containerColor = AppBackground) { padding ->
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding),
color = AppBackground,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(AppBackground) .background(AppBackground)
.verticalScroll(screenScrollState) .padding(12.dp),
.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Header(levels, currentLevelIndex, completedLevels) { index -> FixedHeader()
currentLevelIndex = index PaneWorkspace(
repo = runtime.prepareLevel(levels[index]) paneLayout = paneLayout,
output = listOf("Loaded level: ${levels[index].title}") onMovePane = { paneId, delta ->
clearCommandInput() updatePaneLayout { layout -> movePane(layout, paneId, delta) }
suppressedImeEcho = null },
hintIndex = 0 onTogglePane = { paneId ->
historyIndex = -1 updatePaneLayout { layout ->
historyDraft = "" val collapsed = layout.collapsed.toMutableSet()
if (!collapsed.add(paneId)) collapsed.remove(paneId)
layout.copy(collapsed = collapsed)
} }
ModeBar(mode = mode, onModeSelected = { mode = it }) },
LevelCard( onResize = { upper, lower, dragFraction ->
updatePaneLayout { layout -> resizePanes(layout, upper, lower, dragFraction) }
},
levelsContent = {
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
},
visualContent = { VisualPane(repo) },
exerciseContent = {
ExercisePane(
level = currentLevel, level = currentLevel,
onHint = { onHint = {
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level." val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
@@ -252,10 +390,9 @@ fun GitHugApp() {
}, },
onReset = { resetCurrentLevel() }, onReset = { resetCurrentLevel() },
) )
if (mode == PlayMode.VISUAL) { },
VisualizationPanel(repo) terminalContent = {
} TerminalPane(
TerminalPanel(
output = output, output = output,
inputFieldVersion = inputFieldVersion, inputFieldVersion = inputFieldVersion,
commandInput = commandInput, commandInput = commandInput,
@@ -264,7 +401,7 @@ fun GitHugApp() {
if (blockedEcho != null && commandInput.text.isEmpty()) { if (blockedEcho != null && commandInput.text.isEmpty()) {
val blockedTrimmed = blockedEcho.trim() val blockedTrimmed = blockedEcho.trim()
if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) { if (it.text == blockedEcho || (blockedTrimmed.isNotEmpty() && it.text == blockedTrimmed)) {
return@TerminalPanel return@TerminalPane
} }
} }
suppressedImeEcho = null suppressedImeEcho = null
@@ -277,9 +414,8 @@ fun GitHugApp() {
onCursorRight = { moveCursor(1) }, onCursorRight = { moveCursor(1) },
onHistoryUp = { historyUp() }, onHistoryUp = { historyUp() },
onHistoryDown = { historyDown() }, onHistoryDown = { historyDown() },
modifier = Modifier )
.fillMaxWidth() },
.heightIn(min = 260.dp)
) )
} }
} }
@@ -288,58 +424,346 @@ fun GitHugApp() {
} }
@Composable @Composable
private fun Header(levels: List<Level>, currentLevelIndex: Int, completedLevels: Set<String>, onSelect: (Int) -> Unit) { private fun FixedHeader() {
Card(colors = CardDefaults.cardColors(containerColor = PanelPrimary)) { Card(
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { colors = CardDefaults.cardColors(containerColor = PanelPrimary),
shape = RoundedCornerShape(18.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Image(
painter = painterResource(id = R.drawable.githug_android_logo),
contentDescription = "GitHug Android logo",
modifier = Modifier.size(40.dp),
)
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary) Text("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary)
Text("CLI-first Git learning on Android, with optional repository visualization.", color = TextSecondary) Text("Customizable panes for Git learning on phone and tablet.", color = TextSecondary)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { }
}
}
}
@Composable
private fun PaneWorkspace(
paneLayout: PaneLayout,
onMovePane: (PaneId, Int) -> Unit,
onTogglePane: (PaneId) -> Unit,
onResize: (PaneId, PaneId, Float) -> Unit,
levelsContent: @Composable () -> Unit,
visualContent: @Composable () -> Unit,
exerciseContent: @Composable () -> Unit,
terminalContent: @Composable () -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
paneLayout.order.forEachIndexed { index, paneId ->
val isCollapsed = paneId in paneLayout.collapsed
if (isCollapsed) {
CollapsedPaneCard(
paneId = paneId,
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
)
} else {
PaneCard(
paneId = paneId,
modifier = Modifier
.fillMaxWidth()
.weight((paneLayout.weights[paneId] ?: 1f).coerceAtLeast(0.6f)),
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
) {
when (paneId) {
PaneId.LEVELS -> levelsContent()
PaneId.VISUAL -> visualContent()
PaneId.EXERCISE -> exerciseContent()
PaneId.TERMINAL -> terminalContent()
}
}
}
if (index < paneLayout.order.lastIndex) {
val upper = paneId
val lower = paneLayout.order[index + 1]
PaneDivider(
enabled = upper !in paneLayout.collapsed && lower !in paneLayout.collapsed,
onDrag = { deltaPx ->
val fraction = deltaPx / (heightBasis * 4f)
onResize(upper, lower, fraction)
},
)
}
}
}
}
}
@Composable
private fun PaneCard(
paneId: PaneId,
modifier: Modifier = Modifier,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.fillMaxSize()) {
PaneHeader(
paneId = paneId,
collapsed = false,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
onMoveUp = onMoveUp,
onMoveDown = onMoveDown,
onToggleCollapse = onToggleCollapse,
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
content()
}
}
}
}
@Composable
private fun CollapsedPaneCard(
paneId: PaneId,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
shape = RoundedCornerShape(16.dp),
) {
PaneHeader(
paneId = paneId,
collapsed = true,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
onMoveUp = onMoveUp,
onMoveDown = onMoveDown,
onToggleCollapse = onToggleCollapse,
)
}
}
@Composable
private fun PaneHeader(
paneId: PaneId,
collapsed: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (collapsed) PanelTertiary else PanelPrimary)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(paneId.title, color = TextPrimary, fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
HeaderActionButton(label = if (collapsed) "Expand" else "Collapse", onClick = onToggleCollapse)
HeaderActionButton(label = "", enabled = canMoveUp, onClick = onMoveUp)
HeaderActionButton(label = "", enabled = canMoveDown, onClick = onMoveDown)
}
}
}
@Composable
private fun HeaderActionButton(
label: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = Accent,
disabledContentColor = TextMuted,
),
) {
Text(label, fontSize = 12.sp)
}
}
@Composable
private fun PaneDivider(
enabled: Boolean,
onDrag: (Float) -> Unit,
) {
val dragState = rememberDraggableState { delta ->
if (enabled) onDrag(delta)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(12.dp)
.draggable(
state = dragState,
orientation = Orientation.Vertical,
enabled = enabled,
),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
.background(if (enabled) Accent.copy(alpha = 0.45f) else PanelTertiary),
)
}
}
@Composable
private fun LevelsPane(
levels: List<Level>,
currentLevelIndex: Int,
completedLevels: Set<String>,
onSelect: (Int) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
levels.forEachIndexed { index, level -> levels.forEachIndexed { index, level ->
Card(
colors = CardDefaults.cardColors(
containerColor = if (index == currentLevelIndex) PanelTertiary else PanelPrimary,
),
shape = RoundedCornerShape(12.dp),
) {
TextButton( TextButton(
onClick = { onSelect(index) }, onClick = { onSelect(index) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors( colors = ButtonDefaults.textButtonColors(
contentColor = if (index == currentLevelIndex) Accent else TextSecondary contentColor = if (index == currentLevelIndex) Accent else TextSecondary,
) ),
) { ) {
Text( Text(
text = buildString { text = buildString {
append(if (level.id in completedLevels) "" else "") append(if (level.id in completedLevels) "" else "")
append(level.title) append(level.title)
} },
modifier = Modifier.fillMaxWidth(),
) )
} }
} }
} }
} }
} }
}
@Composable @Composable
private fun ModeBar( private fun VisualPane(repo: RepoState) {
mode: PlayMode, val configuration = LocalConfiguration.current
onModeSelected: (PlayMode) -> Unit, val isWide = configuration.screenWidthDp >= 840
if (isWide) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { InfoPaneCard(
PlayMode.entries.forEach { title = "Workspace",
FilterChip( content = repo.files.joinToString("\n") {
selected = mode == it, "${if (it.staged) "[staged]" else "[file] "} ${it.name}"
onClick = { onModeSelected(it) }, }.ifBlank { "(empty)" },
label = { Text(it.label) }, modifier = Modifier.weight(1f),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Accent,
selectedLabelColor = Color.Black,
containerColor = PanelSecondary,
labelColor = TextPrimary
) )
InfoPaneCard(
title = "Branches",
content = buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(),
modifier = Modifier.weight(1f),
)
InfoPaneCard(
title = "Commits",
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
modifier = Modifier.weight(1f),
)
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
InfoPaneCard(
title = "Workspace",
content = repo.files.joinToString("\n") {
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
}.ifBlank { "(empty)" },
)
InfoPaneCard(
title = "Branches",
content = buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(),
)
InfoPaneCard(
title = "Commits",
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
) )
} }
} }
} }
@Composable @Composable
private fun LevelCard(level: Level, onHint: () -> Unit, onReset: () -> Unit) { private fun ExercisePane(
Card(colors = CardDefaults.cardColors(containerColor = PanelSecondary)) { level: Level,
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { onHint: () -> Unit,
onReset: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary) Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary)
Text(level.description, color = TextSecondary) Text(level.description, color = TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -347,44 +771,57 @@ private fun LevelCard(level: Level, onHint: () -> Unit, onReset: () -> Unit) {
onClick = onHint, onClick = onHint,
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = Accent, containerColor = Accent,
contentColor = Color.Black contentColor = Color.Black,
) ),
) { Text("Hint") } ) {
Text("Hint")
}
TextButton( TextButton(
onClick = onReset, onClick = onReset,
colors = ButtonDefaults.textButtonColors(contentColor = Accent) colors = ButtonDefaults.textButtonColors(contentColor = Accent),
) { Text("Reset level") } ) {
Text("Reset level")
}
}
if (level.commandSuggestions.isNotEmpty()) {
Text("Suggestions", color = TextPrimary, fontWeight = FontWeight.Bold)
level.commandSuggestions.forEach { suggestion ->
Text(suggestion, color = TextSecondary, fontFamily = FontFamily.Monospace)
} }
} }
} }
} }
@Composable @Composable
private fun VisualizationPanel(repo: RepoState) { private fun InfoPaneCard(
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) { title: String,
InfoCard("Workspace", repo.files.joinToString("\n") { content: String,
"${if (it.staged) "[staged]" else "[file] "} ${it.name}" modifier: Modifier = Modifier,
}.ifBlank { "(empty)" }, Modifier.weight(1f)) ) {
InfoCard("Branches", buildString { Card(
appendLine("HEAD -> ${repo.headBranch}") modifier = modifier.fillMaxHeight(),
repo.branches.forEach { (name, _) -> appendLine(name) } colors = CardDefaults.cardColors(containerColor = PanelPrimary),
}.trim(), Modifier.weight(1f)) shape = RoundedCornerShape(14.dp),
InfoCard("Commits", repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, Modifier.weight(1f)) ) {
} Column(
} modifier = Modifier
.fillMaxSize()
@Composable .padding(12.dp),
private fun InfoCard(title: String, content: String, modifier: Modifier = Modifier) { verticalArrangement = Arrangement.spacedBy(8.dp),
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(title, color = TextPrimary, fontWeight = FontWeight.Bold)
Text(content, color = TextSecondary, fontFamily = FontFamily.Monospace) Text(
content,
color = TextSecondary,
fontFamily = FontFamily.Monospace,
modifier = Modifier.verticalScroll(rememberScrollState()),
)
} }
} }
} }
@Composable @Composable
private fun TerminalPanel( private fun TerminalPane(
output: List<String>, output: List<String>,
inputFieldVersion: Int, inputFieldVersion: Int,
commandInput: TextFieldValue, commandInput: TextFieldValue,
@@ -396,11 +833,13 @@ private fun TerminalPanel(
onCursorRight: () -> Unit, onCursorRight: () -> Unit,
onHistoryUp: () -> Unit, onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit, onHistoryDown: () -> Unit,
modifier: Modifier = Modifier,
) { ) {
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val horizontalScroll = rememberScrollState()
val outputVerticalScroll = rememberScrollState()
val terminalMinWidth = 640.dp
LaunchedEffect(inputFieldVersion) { LaunchedEffect(inputFieldVersion) {
if (inputFieldVersion == 0) return@LaunchedEffect if (inputFieldVersion == 0) return@LaunchedEffect
@@ -415,17 +854,38 @@ private fun TerminalPanel(
onRun() onRun()
} }
Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = TerminalBackground)) { Box(
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) { modifier = Modifier
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { .fillMaxSize()
.background(TerminalBackground, RoundedCornerShape(12.dp))
.padding(12.dp)
.horizontalScroll(horizontalScroll),
) {
Column(
modifier = Modifier
.fillMaxHeight()
.widthIn(min = terminalMinWidth),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("Terminal", color = TextPrimary, fontWeight = FontWeight.Bold) Text("Terminal", color = TextPrimary, fontWeight = FontWeight.Bold)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(10.dp))
.padding(12.dp)
.verticalScroll(outputVerticalScroll),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
output.forEach { line -> output.forEach { line ->
Text( Text(
text = line, text = line,
color = if (line.startsWith("") || line.startsWith("🏁")) Success else TextSecondary, color = if (line.startsWith("") || line.startsWith("🏁")) Success else TextSecondary,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace,
) )
} }
}
}
SpecialKeyBar( SpecialKeyBar(
onTab = onTab, onTab = onTab,
onHelp = onHelp, onHelp = onHelp,
@@ -439,13 +899,14 @@ private fun TerminalPanel(
.fillMaxWidth() .fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(8.dp)) .background(PanelPrimary, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp), .padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = "$", text = "$",
color = Accent, color = Accent,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
) )
key(inputFieldVersion) { key(inputFieldVersion) {
BasicTextField( BasicTextField(
@@ -462,14 +923,13 @@ private fun TerminalPanel(
keyboardType = KeyboardType.Ascii, keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
), ),
keyboardActions = KeyboardActions(onDone = { submitCommand() }) keyboardActions = KeyboardActions(onDone = { submitCommand() }),
) )
} }
} }
} }
} }
} }
}
@Composable @Composable
private fun SpecialKeyBar( private fun SpecialKeyBar(
@@ -482,7 +942,7 @@ private fun SpecialKeyBar(
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
TerminalKeyButton(label = "", onClick = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default) TerminalKeyButton(label = "", onClick = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default)
TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.dp), fontFamily = FontFamily.Default) TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.dp), fontFamily = FontFamily.Default)
@@ -506,18 +966,54 @@ private fun TerminalKeyButton(
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = PanelSecondary, containerColor = PanelSecondary,
contentColor = TextPrimary contentColor = TextPrimary,
) ),
) { ) {
Text( Text(
text = label, text = label,
fontFamily = fontFamily, fontFamily = fontFamily,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontSize = 13.sp fontSize = 13.sp,
) )
} }
} }
private fun movePane(layout: PaneLayout, paneId: PaneId, delta: Int): PaneLayout {
val currentIndex = layout.order.indexOf(paneId)
if (currentIndex == -1) return layout
val targetIndex = (currentIndex + delta).coerceIn(0, layout.order.lastIndex)
if (currentIndex == targetIndex) return layout
val updatedOrder = layout.order.toMutableList()
updatedOrder.removeAt(currentIndex)
updatedOrder.add(targetIndex, paneId)
return layout.copy(order = updatedOrder)
}
private fun resizePanes(
layout: PaneLayout,
upper: PaneId,
lower: PaneId,
dragFraction: Float,
): PaneLayout {
if (upper in layout.collapsed || lower in layout.collapsed) return layout
val minWeight = 0.6f
val upperWeight = layout.weights[upper] ?: 1f
val lowerWeight = layout.weights[lower] ?: 1f
val scaledDelta = dragFraction.coerceIn(-0.75f, 0.75f)
val newUpper = (upperWeight + scaledDelta).coerceAtLeast(minWeight)
val newLower = (lowerWeight - scaledDelta).coerceAtLeast(minWeight)
return layout.copy(
weights = layout.weights + mapOf(
upper to newUpper,
lower to newLower,
)
)
}
private fun commonPrefix(values: List<String>): String { private fun commonPrefix(values: List<String>): String {
if (values.isEmpty()) return "" if (values.isEmpty()) return ""
var prefix = values.first() var prefix = values.first()

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB