Changed files:\napp/build.gradle.kts app/src/main/java/solutions/tretter/githugandroid/GameProgressStore.kt app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
388 lines
17 KiB
Kotlin
388 lines
17 KiB
Kotlin
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 gameProgressStore = remember(context) { GameProgressStore(context.applicationContext) }
|
|
val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout())
|
|
val persistedCompletedLevels by gameProgressStore.completedLevelsFlow.collectAsState(initial = emptySet())
|
|
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("") }
|
|
var hasRestoredProgress by remember { mutableStateOf(false) }
|
|
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
|
|
}
|
|
}
|
|
|
|
LaunchedEffect(persistedCompletedLevels) {
|
|
completedLevels = persistedCompletedLevels
|
|
if (!hasRestoredProgress) {
|
|
hasRestoredProgress = true
|
|
val firstIncompleteIndex = levels.indexOfFirst { it.id !in persistedCompletedLevels }
|
|
val resumeIndex = firstIncompleteIndex.takeIf { it >= 0 } ?: levels.lastIndex
|
|
currentLevelIndex = resumeIndex
|
|
repo = runtime.prepareLevel(levels[resumeIndex])
|
|
output = listOf(
|
|
if (persistedCompletedLevels.size == levels.size) {
|
|
"🏁 All Githug levels completed."
|
|
} else if (persistedCompletedLevels.isEmpty()) {
|
|
runtime.startupBanner()
|
|
} else {
|
|
"Resumed at level: ${levels[resumeIndex].title}"
|
|
}
|
|
)
|
|
commandInput = TextFieldValue(text = "", selection = TextRange.Zero)
|
|
suppressedImeEcho = null
|
|
hintIndex = 0
|
|
activeExerciseDetail = null
|
|
visibleHint = null
|
|
historyIndex = -1
|
|
historyDraft = ""
|
|
paneLayout = paneLayout.copy(
|
|
weights = paneLayout.weights + recommendedPaneWeights(
|
|
heights = recommendedPaneHeights(
|
|
level = levels[resumeIndex],
|
|
levelCount = levels.size,
|
|
outputLineCount = 1,
|
|
screenHeightDp = screenHeightDp,
|
|
suggestionsVisible = false,
|
|
visibleHint = null,
|
|
),
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
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) {
|
|
val updatedCompletedLevels = completedLevels + currentLevel.id
|
|
completedLevels = updatedCompletedLevels
|
|
scope.launch { gameProgressStore.saveCompletedLevels(updatedCompletedLevels) }
|
|
val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels }
|
|
if (nextLevelIndex >= 0) {
|
|
loadLevel(nextLevelIndex)
|
|
} 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))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|