Auto-commit after successful build: update app gameplay/UI, improve build setup
Changed files:\napp/build.gradle.kts app/src/main/java/solutions/tretter/githugandroid/Exercise.kt app/src/main/java/solutions/tretter/githugandroid/GameModels.kt app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt app/src/main/java/solutions/tretter/githugandroid/GitHugTheme.kt app/src/main/java/solutions/tretter/githugandroid/GitRuntime.kt app/src/main/java/solutions/tretter/githugandroid/Header.kt app/src/main/java/solutions/tretter/githugandroid/Levels.kt app/src/main/java/solutions/tretter/githugandroid/MainActivity.kt app/src/main/java/solutions/tretter/githugandroid/PaneLayoutLogic.kt app/src/main/java/solutions/tretter/githugandroid/PaneLayoutModels.kt app/src/main/java/solutions/tretter/githugandroid/PaneLayoutStore.kt app/src/main/java/solutions/tretter/githugandroid/Panes.kt app/src/main/java/solutions/tretter/githugandroid/Terminal.kt app/src/main/java/solutions/tretter/githugandroid/Visual.kt app/src/main/java/solutions/tretter/githugandroid/levels/AdvancedLevels.kt app/src/main/java/solutions/tretter/githugandroid/levels/CoreLevels.kt app/src/main/java/solutions/tretter/githugandroid/levels/LevelCatalog.kt
This commit is contained in:
343
app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
Normal file
343
app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
Normal file
@@ -0,0 +1,343 @@
|
||||
package solutions.tretter.githugandroid
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun GitHugApp() {
|
||||
MaterialTheme(colorScheme = GitHugColorScheme) {
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val levels = remember { sampleLevels() }
|
||||
val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) }
|
||||
val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) }
|
||||
val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout())
|
||||
val scope = rememberCoroutineScope()
|
||||
val screenScrollState = rememberScrollState()
|
||||
val screenHeightDp = configuration.screenHeightDp
|
||||
|
||||
var paneLayout by remember { mutableStateOf(defaultPaneLayout()) }
|
||||
var currentLevelIndex by remember { mutableStateOf(0) }
|
||||
var repo by remember { mutableStateOf(runtime.prepareLevel(levels.first())) }
|
||||
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(runtime.startupBanner())) }
|
||||
var hintIndex by remember { mutableStateOf(0) }
|
||||
var activeExerciseDetail by remember { mutableStateOf<ExerciseDetailPanel?>(null) }
|
||||
var visibleHint by remember { mutableStateOf<String?>(null) }
|
||||
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(persistedPaneLayout) {
|
||||
paneLayout = persistedPaneLayout
|
||||
}
|
||||
|
||||
fun updatePaneLayout(transform: (PaneLayout) -> PaneLayout, persist: Boolean = true) {
|
||||
val updated = transform(paneLayout)
|
||||
paneLayout = updated
|
||||
if (persist) {
|
||||
scope.launch { paneLayoutStore.save(updated) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCommandInput(recreateField: Boolean = false) {
|
||||
commandInput = TextFieldValue(text = "", selection = TextRange.Zero)
|
||||
if (recreateField) {
|
||||
inputFieldVersion += 1
|
||||
}
|
||||
}
|
||||
|
||||
fun applyRecommendedPaneWeights(persist: Boolean = true) {
|
||||
updatePaneLayout(
|
||||
transform = { layout ->
|
||||
layout.copy(
|
||||
weights = layout.weights + recommendedPaneWeights(
|
||||
heights = recommendedPaneHeights(
|
||||
level = currentLevel,
|
||||
levelCount = levels.size,
|
||||
outputLineCount = output.size,
|
||||
screenHeightDp = screenHeightDp,
|
||||
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||
visibleHint = visibleHint,
|
||||
),
|
||||
)
|
||||
)
|
||||
},
|
||||
persist = persist,
|
||||
)
|
||||
}
|
||||
|
||||
val recommendedHeights = recommendedPaneHeights(
|
||||
level = currentLevel,
|
||||
levelCount = levels.size,
|
||||
outputLineCount = output.size,
|
||||
screenHeightDp = screenHeightDp,
|
||||
suggestionsVisible = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||
visibleHint = visibleHint,
|
||||
)
|
||||
val recommendedWeights = recommendedPaneWeights(
|
||||
heights = recommendedHeights,
|
||||
)
|
||||
val workspaceHeight = recommendedWorkspaceHeight(
|
||||
paneLayout = paneLayout,
|
||||
recommendedHeights = recommendedHeights,
|
||||
)
|
||||
|
||||
fun resetCurrentLevel(message: String = "Level reset.") {
|
||||
repo = runtime.prepareLevel(currentLevel)
|
||||
clearCommandInput()
|
||||
suppressedImeEcho = null
|
||||
output = listOf(message)
|
||||
hintIndex = 0
|
||||
activeExerciseDetail = null
|
||||
visibleHint = null
|
||||
historyIndex = -1
|
||||
historyDraft = ""
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
}
|
||||
|
||||
fun loadLevel(index: Int) {
|
||||
currentLevelIndex = index
|
||||
repo = runtime.prepareLevel(levels[index])
|
||||
output = listOf("Loaded level: ${levels[index].title}")
|
||||
clearCommandInput()
|
||||
suppressedImeEcho = null
|
||||
hintIndex = 0
|
||||
activeExerciseDetail = null
|
||||
visibleHint = null
|
||||
historyIndex = -1
|
||||
historyDraft = ""
|
||||
paneLayout = paneLayout.copy(
|
||||
weights = paneLayout.weights + recommendedPaneWeights(
|
||||
heights = recommendedPaneHeights(
|
||||
level = levels[index],
|
||||
levelCount = levels.size,
|
||||
outputLineCount = 1,
|
||||
screenHeightDp = screenHeightDp,
|
||||
suggestionsVisible = false,
|
||||
visibleHint = null,
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
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) = runtime.execute(currentLevel, repo, raw)
|
||||
val solvedAfterCommand = currentLevel.validator(newRepo, raw)
|
||||
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) {
|
||||
loadLevel(currentLevelIndex + 1)
|
||||
} else {
|
||||
repo = newRepo
|
||||
output = listOf("🏁 All Githug levels completed.")
|
||||
clearCommandInput()
|
||||
suppressedImeEcho = null
|
||||
}
|
||||
} else {
|
||||
repo = newRepo
|
||||
output = newOutput
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun showCommandHelp() {
|
||||
output = output + runtime.commandReferenceLines()
|
||||
}
|
||||
|
||||
Scaffold(containerColor = AppBackground) { padding ->
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
color = AppBackground,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppBackground)
|
||||
.verticalScroll(screenScrollState)
|
||||
.imePadding()
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FixedHeader()
|
||||
PaneWorkspace(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
paneLayout = paneLayout,
|
||||
onMovePane = { paneId, delta ->
|
||||
updatePaneLayout(transform = { layout -> movePane(layout, paneId, delta) })
|
||||
},
|
||||
onTogglePane = { paneId ->
|
||||
updatePaneLayout(transform = { layout ->
|
||||
val collapsed = layout.collapsed.toMutableSet()
|
||||
if (!collapsed.add(paneId)) collapsed.remove(paneId)
|
||||
layout.copy(collapsed = collapsed)
|
||||
})
|
||||
},
|
||||
levelsContent = {
|
||||
LevelsPane(levels, currentLevelIndex, completedLevels) { index -> loadLevel(index) }
|
||||
},
|
||||
visualContent = { VisualPane(repo) },
|
||||
exerciseContent = {
|
||||
ExercisePane(
|
||||
level = currentLevel,
|
||||
visibleHint = if (activeExerciseDetail == ExerciseDetailPanel.HINT) visibleHint else null,
|
||||
suggestionsExpanded = activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS,
|
||||
onToggleSuggestions = {
|
||||
activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS
|
||||
if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) {
|
||||
visibleHint = null
|
||||
}
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
},
|
||||
onHint = {
|
||||
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
|
||||
visibleHint = hint
|
||||
activeExerciseDetail = ExerciseDetailPanel.HINT
|
||||
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
|
||||
applyRecommendedPaneWeights(persist = false)
|
||||
},
|
||||
onReset = { resetCurrentLevel() },
|
||||
)
|
||||
},
|
||||
terminalContent = {
|
||||
TerminalPane(
|
||||
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@TerminalPane
|
||||
}
|
||||
}
|
||||
suppressedImeEcho = null
|
||||
commandInput = it
|
||||
},
|
||||
onRun = { runCommand() },
|
||||
onTab = { tabComplete() },
|
||||
onHelp = { showCommandHelp() },
|
||||
onCursorLeft = { moveCursor(-1) },
|
||||
onCursorRight = { moveCursor(1) },
|
||||
onHistoryUp = { historyUp() },
|
||||
onHistoryDown = { historyDown() },
|
||||
)
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(220.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user