Refactor large Kotlin source files

- Split core game models from sandbox engine, native level setup, and repo state saving.

- Move app overlays and completion helpers out of GitHugApp.

- Extract the terminal special key bar into its own component.
This commit is contained in:
Joe Tretter
2026-05-09 13:52:13 -05:00
parent 300bd262dd
commit edec66bfe6
9 changed files with 1139 additions and 1110 deletions

View File

@@ -1,35 +1,18 @@
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
@@ -39,25 +22,16 @@ 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.text.input.TextFieldValue
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() {
@@ -653,266 +627,3 @@ fun GitHugApp() {
}
}
}
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)
}
}
}