Files
Githug-Android/app/src/main/java/solutions/tretter/githugandroid/GitHugApp.kt
Joe Tretter 4fab442939 Require native Git runtime and bundle full manpages
- Remove the app fallback path when native Git is unavailable and show a startup blocker instead
- Fix native level setup for staged file counting and cherry-pick parity
- Improve manpage loading/search and bundle full Git documentation assets
- Integrate Git cross-compilation into AndroidProjectTooling.sh and remove debug AAB support
2026-05-08 17:43:46 -05:00

919 lines
39 KiB
Kotlin

package solutions.tretter.githugandroid
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.sp
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
@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<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) }
var editorState by remember { mutableStateOf<TextEditorState?>(null) }
var gitMessageEditorState by remember { mutableStateOf<GitMessageEditorState?>(null) }
var manPageState by remember { mutableStateOf<ManPageState?>(null) }
var solvedCelebrationTitle by remember { mutableStateOf<String?>(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<Rect?>(null) }
var exerciseBounds by remember { mutableStateOf<Rect?>(null) }
var promptBounds by remember { mutableStateOf<Rect?>(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<String>, 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<String> = 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<String>, 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)
}
}
}
internal fun fileCompletionCandidates(repo: RepoState): List<String> {
val prefix = repo.currentDirPrefix()
return repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
.filter { it.isNotBlank() }
}
internal fun directoryCompletionCandidates(repo: RepoState): List<String> {
val prefix = repo.currentDirPrefix()
return repo.files
.filterNot { it.deleted }
.map { it.name }
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
.flatMap { file ->
val parts = file.split('/').dropLast(1)
parts.indices.map { index -> parts.take(index + 1).joinToString("/") + "/" }
}
.distinct()
}
private fun RepoState.currentDirPrefix(): String {
return if (currentDir == ".") "" else currentDir.trimEnd('/') + "/"
}
@Composable
private fun HelpCalloutOverlay(
showHelpOnStart: Boolean,
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
workspaceBounds: Rect?,
exerciseBounds: Rect?,
promptBounds: Rect?,
) {
val density = LocalDensity.current
val workspaceTop = workspaceBounds?.top ?: 0f
val insetPx = with(density) { 14.dp.roundToPx() }
Box(
modifier = Modifier.fillMaxSize(),
) {
HelpBubble(
text = "Read the exercise description, then solve it by entering commands below.",
modifier = exerciseBounds?.let { bounds ->
Modifier.offset {
IntOffset(
x = insetPx,
y = (bounds.top - workspaceTop - insetPx).roundToInt().coerceAtLeast(0),
)
}
} ?: Modifier
.align(Alignment.TopStart)
.padding(horizontal = 14.dp, vertical = 10.dp),
)
HelpBubbleWithControls(
modifier = promptBounds?.let { bounds ->
val bubbleHeight = with(density) { 190.dp.roundToPx() }
Modifier.offset {
IntOffset(
x = insetPx,
y = (bounds.top - workspaceTop - bubbleHeight).roundToInt().coerceAtLeast(0),
)
}
} ?: Modifier
.align(Alignment.BottomStart)
.padding(start = 14.dp, bottom = 96.dp),
text = "Tap the prompt to enter a command or answer.",
showHelpOnStart = showHelpOnStart,
onShowHelpOnStartChange = onShowHelpOnStartChange,
onOk = onOk,
onClose = onClose,
)
}
}
@Composable
private fun HelpBubbleWithControls(
text: String,
showHelpOnStart: Boolean,
onShowHelpOnStartChange: (Boolean) -> Unit,
onOk: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val bubbleColor = Color(0xFFFFF1A8)
Column(modifier = modifier.fillMaxWidth(0.9f)) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = text,
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Checkbox(
checked = showHelpOnStart,
onCheckedChange = onShowHelpOnStartChange,
colors = CheckboxDefaults.colors(
checkedColor = Color(0xFF161000),
uncheckedColor = Color(0xFF6E5A00),
checkmarkColor = bubbleColor,
),
)
Text("Show help on start", color = Color(0xFF161000))
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onOk,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF161000),
contentColor = bubbleColor,
),
) {
Text("OK")
}
TextButton(onClick = onClose) {
Text("Close", color = Color(0xFF161000))
}
}
}
}
Canvas(
modifier = Modifier
.padding(start = 28.dp)
.size(width = 26.dp, height = 13.dp),
) {
drawPath(
path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width * 0.25f, size.height)
close()
},
color = bubbleColor,
)
}
}
}
@Composable
private fun HelpBubble(
text: String,
modifier: Modifier = Modifier,
) {
val bubbleColor = Color(0xFFFFF1A8)
Column(modifier = modifier.fillMaxWidth(0.86f)) {
Box(
modifier = Modifier
.background(bubbleColor, RoundedCornerShape(18.dp))
.padding(horizontal = 14.dp, vertical = 10.dp),
) {
Text(
text = text,
color = Color(0xFF161000),
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
}
Canvas(
modifier = Modifier
.padding(start = 28.dp)
.size(width = 26.dp, height = 13.dp),
) {
drawPath(
path = Path().apply {
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width * 0.25f, size.height)
close()
},
color = bubbleColor,
)
}
}
}
@Composable
private fun MissingNativeGitScreen(message: String) {
Surface(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.padding(24.dp),
color = AppBackground,
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start,
) {
Text(
text = "Native Git unavailable",
color = TextPrimary,
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
)
Text(
text = message,
modifier = Modifier.padding(top = 12.dp),
color = TextSecondary,
fontSize = 15.sp,
)
}
}
}
@Composable
private fun SolvedCelebrationOverlay(title: String) {
val transition = rememberInfiniteTransition(label = "solved-celebration")
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1_200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "confetti-progress",
)
val colors = listOf(Accent, Success, Color(0xFFFFD166), Color(0xFFFF6B6B), Color(0xFF9BF6FF))
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Canvas(modifier = Modifier.fillMaxSize()) {
repeat(42) { index ->
val x = ((index * 73) % 100) / 100f * size.width
val baseY = ((index * 37) % 100) / 100f * size.height
val y = (baseY + progress * size.height * 0.6f) % size.height
val pieceSize = 6.dp.toPx() + (index % 4) * 2.dp.toPx()
drawCircle(
color = colors[index % colors.size],
radius = pieceSize / 2f,
center = Offset(x, y),
alpha = 0.85f,
)
}
}
Column(
modifier = Modifier
.background(PanelPrimary.copy(alpha = 0.94f), RoundedCornerShape(24.dp))
.padding(horizontal = 26.dp, vertical = 22.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = "👍", fontSize = 56.sp)
Text(text = "Level solved", color = Success, fontSize = 22.sp)
Text(text = title, color = TextSecondary, fontSize = 14.sp)
}
}
}