package solutions.tretter.githugandroid import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.geometry.Rect import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import kotlinx.coroutines.delay import kotlinx.coroutines.launch @Composable fun GitHugApp() { MaterialTheme(colorScheme = GitHugColorScheme) { val context = LocalContext.current val configuration = LocalConfiguration.current val runtime = remember(context) { GitRepositoryRuntime(context.applicationContext) } if (!runtime.isNativeGitAvailable()) { MissingNativeGitScreen(message = runtime.unavailableMessage()) return@MaterialTheme } val levels = remember { sampleLevels() } val paneLayoutStore = remember(context) { PaneLayoutStore(context.applicationContext) } val gameProgressStore = remember(context) { GameProgressStore(context.applicationContext) } val persistedPaneLayout by paneLayoutStore.layoutFlow.collectAsState(initial = defaultPaneLayout()) val persistedProgress by gameProgressStore.progressFlow.collectAsState(initial = null) 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(null) } var output by remember { mutableStateOf(listOf(runtime.startupBanner())) } var hintIndex by remember { mutableStateOf(0) } var activeExerciseDetail by remember { mutableStateOf(null) } var visibleHint by remember { mutableStateOf(null) } var completedLevels by remember { mutableStateOf(setOf()) } var commandHistory by remember { mutableStateOf(listOf()) } var historyIndex by remember { mutableStateOf(-1) } var historyDraft by remember { mutableStateOf("") } var hasRestoredProgress by remember { mutableStateOf(false) } var editorState by remember { mutableStateOf(null) } var gitMessageEditorState by remember { mutableStateOf(null) } var manPageState by remember { mutableStateOf(null) } var solvedCelebrationTitle by remember { mutableStateOf(null) } var showTerminalInputHint by remember { mutableStateOf(true) } var showExerciseDescriptionHint by remember { mutableStateOf(true) } var showHelpOverlay by remember { mutableStateOf(false) } var showHelpOnStart by remember { mutableStateOf(true) } var helpPreferenceInitialized by remember { mutableStateOf(false) } var workspaceBounds by remember { mutableStateOf(null) } var exerciseBounds by remember { mutableStateOf(null) } var promptBounds by remember { mutableStateOf(null) } val currentLevel = levels[currentLevelIndex] LaunchedEffect(solvedCelebrationTitle) { if (solvedCelebrationTitle != null) { delay(3_000) solvedCelebrationTitle = null } } LaunchedEffect(persistedPaneLayout) { paneLayout = persistedPaneLayout } LaunchedEffect(currentLevelIndex) { delay(100) screenScrollState.animateScrollTo(0) } 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 } } suspend fun persistProgress(completed: Set, activeLevelId: String?) { gameProgressStore.saveProgress(completed, activeLevelId) } LaunchedEffect(persistedProgress) { val restoredProgress = persistedProgress ?: return@LaunchedEffect val restoredCompletedLevels = restoredProgress.completedLevels completedLevels = restoredCompletedLevels showHelpOnStart = restoredProgress.showHelpOnStart if (!helpPreferenceInitialized) { helpPreferenceInitialized = true showHelpOverlay = restoredProgress.showHelpOnStart } AppLog.d( "GitHugApp", "Observed persisted progress completed=${restoredCompletedLevels.sorted()} activeLevelId=${restoredProgress.activeLevelId} restored=$hasRestoredProgress", ) if (!hasRestoredProgress) { hasRestoredProgress = true val resumeIndex = restoredProgress.activeLevelId ?.let { activeId -> levels.indexOfFirst { it.id == activeId }.takeIf { it >= 0 } } ?: levels.indexOfFirst { it.id !in restoredCompletedLevels }.takeIf { it >= 0 } ?: levels.lastIndex AppLog.d( "GitHugApp", "Restoring app to levelIndex=$resumeIndex levelId=${levels[resumeIndex].id}", ) currentLevelIndex = resumeIndex repo = runtime.prepareLevel(levels[resumeIndex]) output = listOf( if (restoredCompletedLevels.size == levels.size) { "🏁 All Githug levels completed." } else if (restoredCompletedLevels.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, ) } fun resetCurrentLevel(message: String = "Level reset.") { val resetLevelId = currentLevel.id val updatedCompletedLevels = completedLevels - resetLevelId completedLevels = updatedCompletedLevels scope.launch { persistProgress(updatedCompletedLevels, resetLevelId) } repo = runtime.prepareLevel(currentLevel) clearCommandInput() suppressedImeEcho = null output = listOf(message) hintIndex = 0 activeExerciseDetail = null visibleHint = null historyIndex = -1 historyDraft = "" editorState = null gitMessageEditorState = null manPageState = null applyRecommendedPaneWeights(persist = false) } fun loadLevel(index: Int, message: List = listOf("Loaded level: ${levels[index].title}")) { AppLog.d("GitHugApp", "Loading level index=$index id=${levels[index].id} title=${levels[index].title}") currentLevelIndex = index repo = runtime.prepareLevel(levels[index]) output = message clearCommandInput() suppressedImeEcho = null hintIndex = 0 activeExerciseDetail = null visibleHint = null historyIndex = -1 historyDraft = "" editorState = null gitMessageEditorState = null manPageState = null 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) val leadingCommand = beforeCursor.trimStart().substringBefore(' ') val isCdCompletion = leadingCommand == "cd" if (token.isBlank() && !isCdCompletion) return val candidates = runtime.completionCandidates(currentLevel, repo, directoriesOnly = isCdCompletion) val matches = candidates.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 applyCommandResult(raw: String, newRepo: RepoState, lines: List, echoCommand: Boolean) { val levelForResult = currentLevel val solvedAfterCommand = levelForResult.validator(newRepo, raw) val wasAlreadyCompleted = currentLevel.id in completedLevels AppLog.d( "GitHugApp", "Command='$raw' level=${levelForResult.id} solved=$solvedAfterCommand alreadyCompleted=$wasAlreadyCompleted completedBefore=${completedLevels.sorted()}", ) val newOutput = buildList { addAll(output) if (echoCommand) { add("$ $raw") } addAll(lines) if (solvedAfterCommand && !wasAlreadyCompleted) { add("") add("👍 Level solved: ${levelForResult.title}") add("Nice. ${completedLevels.size + 1}/${levels.size} levels complete.") } } if (solvedAfterCommand && !wasAlreadyCompleted) { val updatedCompletedLevels = completedLevels + levelForResult.id completedLevels = updatedCompletedLevels solvedCelebrationTitle = levelForResult.title val nextLevelIndex = levels.indexOfFirst { it.id !in updatedCompletedLevels } if (nextLevelIndex >= 0) { val nextLevelId = levels[nextLevelIndex].id AppLog.d( "GitHugApp", "Level solved ${levelForResult.id}; advancing to next incomplete index=$nextLevelIndex id=$nextLevelId", ) scope.launch { persistProgress(updatedCompletedLevels, nextLevelId) } loadLevel(nextLevelIndex, message = emptyList()) } else { AppLog.d("GitHugApp", "All levels completed") scope.launch { persistProgress(updatedCompletedLevels, levels.lastOrNull()?.id) } repo = newRepo output = newOutput + listOf("🏁 All Githug levels completed.") clearCommandInput() suppressedImeEcho = null } } else { AppLog.d("GitHugApp", "Staying on level=${levelForResult.id}") repo = newRepo output = newOutput applyRecommendedPaneWeights(persist = false) } } fun openEditor(invocation: VisualEditorInvocation) { val path = invocation.path.orEmpty() val (content, lines) = if (path.isBlank()) { "" to emptyList() } else { runtime.readEditorFile(currentLevel, repo, path) } output = buildList { addAll(output) add("$ ${invocation.command}") addAll(lines) if (lines.isEmpty()) { add("Opened ${invocation.editor} editor${if (path.isBlank()) "" else " for $path"}") } } if (lines.isEmpty()) { editorState = TextEditorState( editor = invocation.editor, originalCommand = invocation.command, path = path, content = content, saveAsPath = path, ) } applyRecommendedPaneWeights(persist = false) } fun openManPage(invocation: GitHelpInvocation) { val content = runtime.gitManPage(currentLevel, repo, invocation.topic) output = buildList { addAll(output) add("$ ${invocation.command}") add("Opened git help viewer for ${invocation.topic}") } manPageState = ManPageState(topic = invocation.topic, content = content) applyRecommendedPaneWeights(persist = false) } fun openGitMessageEditor(invocation: GitEditorInvocation) { output = buildList { addAll(output) add("$ ${invocation.command}") add("Opened Git message editor") } gitMessageEditorState = GitMessageEditorState( invocation = invocation, content = invocation.initialContent, ) applyRecommendedPaneWeights(persist = false) } fun saveEditor() { val state = editorState ?: return val targetPath = state.saveAsPath.trim() if (targetPath.isBlank()) return val (newRepo, lines) = runtime.writeEditorFile(currentLevel, repo, targetPath, state.content) editorState = null applyCommandResult(state.originalCommand, newRepo, lines, echoCommand = false) } fun saveGitMessageEditor() { val state = gitMessageEditorState ?: return val (newRepo, lines) = runtime.executeGitEditorCommand( level = currentLevel, currentRepo = repo, invocation = state.invocation, message = state.content, ) gitMessageEditorState = null applyCommandResult(state.invocation.command, newRepo, lines, echoCommand = false) } fun runCommand() { val submittedText = commandInput.text val raw = submittedText.trim() if (raw.isBlank()) return showHelpOverlay = false showTerminalInputHint = false showExerciseDescriptionHint = false suppressedImeEcho = submittedText clearCommandInput(recreateField = true) if (commandHistory.lastOrNull() != raw) { commandHistory = commandHistory + raw } historyIndex = -1 historyDraft = "" val editorInvocation = parseVisualEditorInvocation(raw) if (editorInvocation != null) { openEditor(editorInvocation) return } val helpInvocation = parseGitHelpInvocation(raw) if (helpInvocation != null) { openManPage(helpInvocation) return } val gitEditorInvocation = parseGitEditorInvocation(raw) if (gitEditorInvocation != null) { openGitMessageEditor(gitEditorInvocation) return } val (newRepo, lines) = runtime.execute(currentLevel, repo, raw) applyCommandResult(raw, newRepo, lines, echoCommand = true) } fun showCommandHelp() { output = output + runtime.commandReferenceLines() } Scaffold(containerColor = AppBackground) { padding -> Surface( modifier = Modifier .fillMaxSize() .padding(padding), color = AppBackground, ) { Box(modifier = Modifier.fillMaxSize()) { Column( modifier = Modifier .fillMaxSize() .background(AppBackground) .imePadding() .verticalScroll(screenScrollState) .padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { FixedHeader() Box( modifier = Modifier .fillMaxWidth() .onGloballyPositioned { workspaceBounds = it.boundsInRoot() }, ) { 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, showDescriptionHint = false, onBoundsChanged = { exerciseBounds = it }, onToggleSuggestions = { showExerciseDescriptionHint = false showHelpOverlay = false activeExerciseDetail = if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) null else ExerciseDetailPanel.SUGGESTIONS if (activeExerciseDetail == ExerciseDetailPanel.SUGGESTIONS) { visibleHint = null } applyRecommendedPaneWeights(persist = false) }, onHint = { showExerciseDescriptionHint = false showHelpOverlay = false 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 = { showExerciseDescriptionHint = false showHelpOverlay = false resetCurrentLevel() }, ) }, terminalContent = { TerminalPane( output = output, inputFieldVersion = inputFieldVersion, commandInput = commandInput, showInputHint = false, onInputHintDismiss = { showTerminalInputHint = false showHelpOverlay = false }, 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 if (it.text.isNotEmpty()) { showTerminalInputHint = false showExerciseDescriptionHint = false showHelpOverlay = false } commandInput = it }, onRun = { runCommand() }, onTab = { tabComplete() }, onHelp = { showCommandHelp() }, onCursorLeft = { moveCursor(-1) }, onCursorRight = { moveCursor(1) }, onHistoryUp = { historyUp() }, onHistoryDown = { historyDown() }, onPromptBoundsChanged = { promptBounds = it }, ) }, ) if (showHelpOverlay) { HelpCalloutOverlay( showHelpOnStart = showHelpOnStart, onShowHelpOnStartChange = { showHelpOnStart = it }, onOk = { showHelpOverlay = false scope.launch { gameProgressStore.saveShowHelpOnStart(showHelpOnStart) } }, onClose = { showHelpOverlay = false }, workspaceBounds = workspaceBounds, exerciseBounds = exerciseBounds, promptBounds = promptBounds, ) } } } } } } editorState?.let { state -> TextEditorDialog( state = state, onContentChange = { editorState = state.copy(content = it) }, onSaveAsPathChange = { editorState = state.copy(saveAsPath = it) }, onClose = { output = output + "Editor closed without saving" editorState = null }, onSave = { saveEditor() }, ) } gitMessageEditorState?.let { state -> GitMessageEditorDialog( state = state, onContentChange = { gitMessageEditorState = state.copy(content = it) }, onClose = { output = output + "Git editor closed without saving" gitMessageEditorState = null }, onSave = { saveGitMessageEditor() }, ) } manPageState?.let { state -> ManPageDialog( state = state, onClose = { manPageState = null }, ) } solvedCelebrationTitle?.let { title -> SolvedCelebrationOverlay(title = title) } } }