Auto-commit after successful build: update app gameplay/UI, improve build setup

Changed files:\napp/build.gradle.kts
app/src/main/java/com/kawomi/githugandroid/Exercise.kt
app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt
app/src/main/java/com/kawomi/githugandroid/Header.kt
app/src/main/java/com/kawomi/githugandroid/Levels.kt
app/src/main/java/com/kawomi/githugandroid/Panes.kt
app/src/main/java/com/kawomi/githugandroid/Terminal.kt
app/src/main/java/com/kawomi/githugandroid/Visual.kt
This commit is contained in:
Joe Tretter
2026-04-23 20:51:31 -05:00
parent 6012516551
commit 708c618ffa
8 changed files with 810 additions and 705 deletions

View File

@@ -0,0 +1,68 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun ExercisePane(
level: Level,
visibleHint: String?,
suggestionsExpanded: Boolean,
onToggleSuggestions: () -> Unit,
onHint: () -> Unit,
onReset: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary)
Text(level.description, color = TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onHint,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = Color.Black,
),
) {
Text("Hint")
}
TextButton(
onClick = onToggleSuggestions,
colors = ButtonDefaults.textButtonColors(contentColor = Accent),
) {
Text("Suggestions")
}
TextButton(
onClick = onReset,
colors = ButtonDefaults.textButtonColors(contentColor = Accent),
) {
Text("Reset level")
}
}
if (suggestionsExpanded && level.commandSuggestions.isNotEmpty()) {
Text("Suggestions", color = TextPrimary, fontWeight = FontWeight.Bold)
level.commandSuggestions.forEach { suggestion ->
Text(suggestion, color = TextSecondary, fontFamily = FontFamily.Monospace)
}
}
if (visibleHint != null) {
Text("Hint", color = TextPrimary, fontWeight = FontWeight.Bold)
Text(visibleHint, color = TextSecondary)
}
}
}

View File

@@ -1,78 +1,33 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
@@ -393,660 +348,3 @@ fun GitHugApp() {
}
}
@Composable
private fun FixedHeader() {
Card(
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
shape = RoundedCornerShape(14.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Image(
painter = painterResource(id = R.drawable.githug_android_logo),
contentDescription = "GitHug Android logo",
modifier = Modifier.size(28.dp),
)
Text("GitHug Android", style = MaterialTheme.typography.titleLarge, color = TextPrimary)
}
}
}
@Composable
private fun PaneWorkspace(
modifier: Modifier = Modifier,
paneLayout: PaneLayout,
onMovePane: (PaneId, Int) -> Unit,
onTogglePane: (PaneId) -> Unit,
onResize: (PaneId, PaneId, Float) -> Unit,
onResizeFinished: () -> Unit,
levelsContent: @Composable () -> Unit,
visualContent: @Composable () -> Unit,
exerciseContent: @Composable () -> Unit,
terminalContent: @Composable () -> Unit,
) {
BoxWithConstraints(modifier = modifier) {
val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
paneLayout.order.forEachIndexed { index, paneId ->
val isCollapsed = paneId in paneLayout.collapsed
if (isCollapsed) {
CollapsedPaneCard(
paneId = paneId,
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
)
} else {
PaneCard(
paneId = paneId,
modifier = Modifier
.fillMaxWidth()
.weight((paneLayout.weights[paneId] ?: 1f).coerceAtLeast(0.6f)),
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
) {
when (paneId) {
PaneId.LEVELS -> levelsContent()
PaneId.VISUAL -> visualContent()
PaneId.EXERCISE -> exerciseContent()
PaneId.TERMINAL -> terminalContent()
}
}
}
if (index < paneLayout.order.lastIndex) {
val upper = paneId
val lower = paneLayout.order[index + 1]
PaneDivider(
enabled = upper !in paneLayout.collapsed && lower !in paneLayout.collapsed,
onDrag = { deltaPx ->
val fraction = deltaPx / heightBasis
onResize(upper, lower, fraction)
},
onDragFinished = onResizeFinished,
)
}
}
}
}
}
@Composable
private fun PaneCard(
paneId: PaneId,
modifier: Modifier = Modifier,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.fillMaxSize()) {
PaneHeader(
paneId = paneId,
collapsed = false,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
onMoveUp = onMoveUp,
onMoveDown = onMoveDown,
onToggleCollapse = onToggleCollapse,
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
content()
}
}
}
}
@Composable
private fun CollapsedPaneCard(
paneId: PaneId,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
shape = RoundedCornerShape(16.dp),
) {
PaneHeader(
paneId = paneId,
collapsed = true,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
onMoveUp = onMoveUp,
onMoveDown = onMoveDown,
onToggleCollapse = onToggleCollapse,
)
}
}
@Composable
private fun PaneHeader(
paneId: PaneId,
collapsed: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (collapsed) PanelTertiary else PanelPrimary)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(paneId.title, color = TextPrimary, fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
HeaderActionButton(label = if (collapsed) "Expand" else "Collapse", onClick = onToggleCollapse)
HeaderActionButton(label = "", enabled = canMoveUp, onClick = onMoveUp)
HeaderActionButton(label = "", enabled = canMoveDown, onClick = onMoveDown)
}
}
}
@Composable
private fun HeaderActionButton(
label: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = Accent,
disabledContentColor = TextMuted,
),
) {
Text(label, fontSize = 11.sp)
}
}
@Composable
private fun PaneDivider(
enabled: Boolean,
onDrag: (Float) -> Unit,
onDragFinished: () -> Unit,
) {
val dragState = rememberDraggableState { delta ->
if (enabled) onDrag(delta)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(18.dp)
.draggable(
state = dragState,
orientation = Orientation.Vertical,
enabled = enabled,
onDragStopped = { onDragFinished() },
),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(if (enabled) Accent.copy(alpha = 0.45f) else PanelTertiary),
)
Card(
colors = CardDefaults.cardColors(
containerColor = if (enabled) PanelPrimary else PanelTertiary,
),
shape = RoundedCornerShape(999.dp),
modifier = Modifier.padding(start = 28.dp),
) {
Text(
text = "",
color = if (enabled) Accent else TextMuted,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp),
fontSize = 13.sp,
fontWeight = FontWeight.ExtraBold,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RowScope.TerminalInputField(
inputFieldVersion: Int,
commandInput: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
focusRequester: FocusRequester,
bringIntoViewRequester: BringIntoViewRequester,
keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController?,
onSubmit: () -> Unit,
) {
val scope = rememberCoroutineScope()
key(inputFieldVersion) {
BasicTextField(
value = commandInput,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { state ->
if (state.isFocused) {
keyboardController?.show()
scope.launch {
delay(150)
bringIntoViewRequester.bringIntoView()
}
}
},
singleLine = true,
textStyle = TextStyle(
color = TextPrimary,
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
),
cursorBrush = SolidColor(Accent),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
if (commandInput.text.isEmpty()) {
Text(
text = "Enter git command",
color = TextMuted.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
)
}
innerTextField()
}
},
)
}
}
@Composable
private fun LevelsPane(
levels: List<Level>,
currentLevelIndex: Int,
completedLevels: Set<String>,
onSelect: (Int) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
levels.forEachIndexed { index, level ->
Card(
colors = CardDefaults.cardColors(
containerColor = if (index == currentLevelIndex) PanelTertiary else PanelPrimary,
),
shape = RoundedCornerShape(10.dp),
) {
TextButton(
onClick = { onSelect(index) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors(
contentColor = if (index == currentLevelIndex) Accent else TextSecondary,
),
) {
Text(
text = buildString {
append(if (level.id in completedLevels) "" else "")
append(level.title)
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}
@Composable
private fun VisualPane(repo: RepoState) {
val configuration = LocalConfiguration.current
val isWide = configuration.screenWidthDp >= 840
if (isWide) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
InfoPaneCard(
title = "Workspace",
content = repo.files.joinToString("\n") {
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
}.ifBlank { "(empty)" },
modifier = Modifier.weight(1f),
)
InfoPaneCard(
title = "Branches",
content = buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(),
modifier = Modifier.weight(1f),
)
InfoPaneCard(
title = "Commits",
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
modifier = Modifier.weight(1f),
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
InfoPaneCard(
title = "Workspace",
content = repo.files.joinToString("\n") {
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
}.ifBlank { "(empty)" },
)
InfoPaneCard(
title = "Branches",
content = buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(),
)
InfoPaneCard(
title = "Commits",
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
)
}
}
}
@Composable
private fun ExercisePane(
level: Level,
visibleHint: String?,
suggestionsExpanded: Boolean,
onToggleSuggestions: () -> Unit,
onHint: () -> Unit,
onReset: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(level.title, style = MaterialTheme.typography.titleLarge, color = TextPrimary)
Text(level.description, color = TextSecondary)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = onHint,
colors = ButtonDefaults.buttonColors(
containerColor = Accent,
contentColor = Color.Black,
),
) {
Text("Hint")
}
TextButton(
onClick = onToggleSuggestions,
colors = ButtonDefaults.textButtonColors(contentColor = Accent),
) {
Text("Suggestions")
}
TextButton(
onClick = onReset,
colors = ButtonDefaults.textButtonColors(contentColor = Accent),
) {
Text("Reset level")
}
}
if (suggestionsExpanded && level.commandSuggestions.isNotEmpty()) {
Text("Suggestions", color = TextPrimary, fontWeight = FontWeight.Bold)
level.commandSuggestions.forEach { suggestion ->
Text(suggestion, color = TextSecondary, fontFamily = FontFamily.Monospace)
}
}
if (visibleHint != null) {
Text("Hint", color = TextPrimary, fontWeight = FontWeight.Bold)
Text(visibleHint, color = TextSecondary)
}
}
}
@Composable
private fun InfoPaneCard(
title: String,
content: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
shape = RoundedCornerShape(10.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
Text(
content,
color = TextSecondary,
fontFamily = FontFamily.Monospace,
)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TerminalPane(
output: List<String>,
inputFieldVersion: Int,
commandInput: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onRun: () -> Unit,
onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit,
onCursorRight: () -> Unit,
onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit,
) {
val configuration = LocalConfiguration.current
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val horizontalScroll = rememberScrollState()
val outputVerticalScroll = rememberScrollState()
val terminalMinWidth = 80.dp * 7.2f
val terminalPaneMaxHeight = configuration.screenHeightDp.dp * 0.7f
val reservedControlsHeight = 86.dp
val maxOutputHeight = (terminalPaneMaxHeight - reservedControlsHeight).coerceAtLeast(120.dp)
val outputLineHeight = 20.dp
val desiredOutputHeight = (output.size.coerceAtLeast(6) * outputLineHeight.value).dp.coerceAtMost(maxOutputHeight)
LaunchedEffect(inputFieldVersion) {
focusRequester.requestFocus()
keyboardController?.show()
horizontalScroll.scrollTo(0)
}
LaunchedEffect(output.size) {
outputVerticalScroll.animateScrollTo(outputVerticalScroll.maxValue)
}
LaunchedEffect(output.size, commandInput.text) {
if (output.isNotEmpty() && commandInput.text.isEmpty()) {
horizontalScroll.scrollTo(0)
}
}
fun submitCommand() {
focusManager.clearFocus(force = true)
keyboardController?.hide()
onRun()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(TerminalBackground, RoundedCornerShape(12.dp))
.padding(8.dp)
.horizontalScroll(horizontalScroll),
) {
Column(
modifier = Modifier
.fillMaxHeight()
.widthIn(min = terminalMinWidth),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.heightIn(min = 120.dp, max = maxOutputHeight)
.height(desiredOutputHeight)
.fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(10.dp))
.padding(8.dp)
.verticalScroll(outputVerticalScroll),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
output.forEach { line ->
Text(
text = line,
color = if (line.startsWith("") || line.startsWith("🏁")) Success else TextSecondary,
fontFamily = FontFamily.Monospace,
)
}
}
}
SpecialKeyBar(
onTab = onTab,
onHelp = onHelp,
onCursorLeft = onCursorLeft,
onCursorRight = onCursorRight,
onHistoryUp = onHistoryUp,
onHistoryDown = onHistoryDown,
)
Row(
modifier = Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.fillMaxWidth()
.widthIn(min = terminalMinWidth)
.background(PanelPrimary, RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$",
color = Accent,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
)
TerminalInputField(
inputFieldVersion = inputFieldVersion,
commandInput = commandInput,
onValueChange = onValueChange,
focusRequester = focusRequester,
bringIntoViewRequester = bringIntoViewRequester,
keyboardController = keyboardController,
onSubmit = { submitCommand() },
)
}
}
}
}
@Composable
private fun SpecialKeyBar(
onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit,
onCursorRight: () -> Unit,
onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TerminalKeyButton(label = "", onClick = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default)
TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.dp), fontFamily = FontFamily.Default)
TerminalKeyButton(label = "", onClick = onCursorLeft, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onCursorRight, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onHistoryUp, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onHistoryDown, modifier = Modifier.width(40.dp))
}
}
@Composable
private fun TerminalKeyButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
fontFamily: FontFamily = FontFamily.Monospace,
) {
Button(
onClick = onClick,
modifier = modifier.height(28.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PanelSecondary,
contentColor = TextPrimary,
),
) {
Text(
text = label,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
)
}
}

View File

@@ -0,0 +1,41 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
@Composable
fun FixedHeader() {
Card(
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
shape = RoundedCornerShape(14.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Image(
painter = painterResource(id = R.drawable.githug_android_logo),
contentDescription = "GitHug Android logo",
modifier = Modifier.size(28.dp),
)
Text("GitHug Android", style = MaterialTheme.typography.titleLarge, color = TextPrimary)
}
}
}

View File

@@ -0,0 +1,52 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LevelsPane(
levels: List<Level>,
currentLevelIndex: Int,
completedLevels: Set<String>,
onSelect: (Int) -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
levels.forEachIndexed { index, level ->
Card(
colors = CardDefaults.cardColors(
containerColor = if (index == currentLevelIndex) PanelTertiary else PanelPrimary,
),
shape = RoundedCornerShape(10.dp),
) {
TextButton(
onClick = { onSelect(index) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.textButtonColors(
contentColor = if (index == currentLevelIndex) Accent else TextSecondary,
),
) {
Text(
text = buildString {
append(if (level.id in completedLevels) "" else "")
append(level.title)
},
modifier = Modifier.fillMaxWidth(),
)
}
}
}
}
}

View File

@@ -0,0 +1,252 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PaneWorkspace(
modifier: Modifier = Modifier,
paneLayout: PaneLayout,
onMovePane: (PaneId, Int) -> Unit,
onTogglePane: (PaneId) -> Unit,
onResize: (PaneId, PaneId, Float) -> Unit,
onResizeFinished: () -> Unit,
levelsContent: @Composable () -> Unit,
visualContent: @Composable () -> Unit,
exerciseContent: @Composable () -> Unit,
terminalContent: @Composable () -> Unit,
) {
BoxWithConstraints(modifier = modifier) {
val heightBasis = maxHeight.value.takeIf { it > 0f } ?: 1f
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(0.dp),
) {
paneLayout.order.forEachIndexed { index, paneId ->
val isCollapsed = paneId in paneLayout.collapsed
if (isCollapsed) {
CollapsedPaneCard(
paneId = paneId,
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
)
} else {
PaneCard(
paneId = paneId,
modifier = Modifier
.fillMaxWidth()
.weight((paneLayout.weights[paneId] ?: 1f).coerceAtLeast(0.6f)),
canMoveUp = index > 0,
canMoveDown = index < paneLayout.order.lastIndex,
onMoveUp = { onMovePane(paneId, -1) },
onMoveDown = { onMovePane(paneId, 1) },
onToggleCollapse = { onTogglePane(paneId) },
) {
when (paneId) {
PaneId.LEVELS -> levelsContent()
PaneId.VISUAL -> visualContent()
PaneId.EXERCISE -> exerciseContent()
PaneId.TERMINAL -> terminalContent()
}
}
}
if (index < paneLayout.order.lastIndex) {
val upper = paneId
val lower = paneLayout.order[index + 1]
PaneDivider(
enabled = upper !in paneLayout.collapsed && lower !in paneLayout.collapsed,
onDrag = { deltaPx ->
val fraction = deltaPx / heightBasis
onResize(upper, lower, fraction)
},
onDragFinished = onResizeFinished,
)
}
}
}
}
}
@Composable
private fun PaneCard(
paneId: PaneId,
modifier: Modifier = Modifier,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
shape = RoundedCornerShape(16.dp),
) {
Column(modifier = Modifier.fillMaxSize()) {
PaneHeader(
paneId = paneId,
collapsed = false,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
onMoveUp = onMoveUp,
onMoveDown = onMoveDown,
onToggleCollapse = onToggleCollapse,
)
Box(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
content()
}
}
}
}
@Composable
private fun CollapsedPaneCard(
paneId: PaneId,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PanelSecondary),
shape = RoundedCornerShape(16.dp),
) {
PaneHeader(
paneId = paneId,
collapsed = true,
canMoveUp = canMoveUp,
canMoveDown = canMoveDown,
onMoveUp = onMoveUp,
onMoveDown = onMoveDown,
onToggleCollapse = onToggleCollapse,
)
}
}
@Composable
private fun PaneHeader(
paneId: PaneId,
collapsed: Boolean,
canMoveUp: Boolean,
canMoveDown: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggleCollapse: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(if (collapsed) PanelTertiary else PanelPrimary)
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(paneId.title, color = TextPrimary, fontWeight = FontWeight.Bold)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
HeaderActionButton(label = if (collapsed) "Expand" else "Collapse", onClick = onToggleCollapse)
HeaderActionButton(label = "", enabled = canMoveUp, onClick = onMoveUp)
HeaderActionButton(label = "", enabled = canMoveDown, onClick = onMoveDown)
}
}
}
@Composable
private fun HeaderActionButton(
label: String,
enabled: Boolean = true,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
enabled = enabled,
contentPadding = PaddingValues(horizontal = 6.dp, vertical = 0.dp),
colors = ButtonDefaults.textButtonColors(
contentColor = Accent,
disabledContentColor = TextMuted,
),
) {
Text(label, fontSize = 11.sp)
}
}
@Composable
private fun PaneDivider(
enabled: Boolean,
onDrag: (Float) -> Unit,
onDragFinished: () -> Unit,
) {
val dragState = rememberDraggableState { delta ->
if (enabled) onDrag(delta)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(18.dp)
.draggable(
state = dragState,
orientation = Orientation.Vertical,
enabled = enabled,
onDragStopped = { onDragFinished() },
),
contentAlignment = Alignment.CenterStart,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(if (enabled) Accent.copy(alpha = 0.45f) else PanelTertiary),
)
Card(
colors = CardDefaults.cardColors(
containerColor = if (enabled) PanelPrimary else PanelTertiary,
),
shape = RoundedCornerShape(999.dp),
modifier = Modifier.padding(start = 28.dp),
) {
Text(
text = "",
color = if (enabled) Accent else TextMuted,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 1.dp),
fontSize = 13.sp,
fontWeight = FontWeight.ExtraBold,
)
}
}
}

View File

@@ -0,0 +1,291 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.relocation.BringIntoViewRequester
import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TerminalPane(
output: List<String>,
inputFieldVersion: Int,
commandInput: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
onRun: () -> Unit,
onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit,
onCursorRight: () -> Unit,
onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit,
) {
val configuration = LocalConfiguration.current
val focusRequester = remember { FocusRequester() }
val bringIntoViewRequester = remember { BringIntoViewRequester() }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val horizontalScroll = rememberScrollState()
val outputVerticalScroll = rememberScrollState()
val terminalMinWidth = 80.dp * 7.2f
val terminalPaneMaxHeight = configuration.screenHeightDp.dp * 0.7f
val reservedControlsHeight = 86.dp
val maxOutputHeight = (terminalPaneMaxHeight - reservedControlsHeight).coerceAtLeast(120.dp)
val outputLineHeight = 20.dp
val desiredOutputHeight = (output.size.coerceAtLeast(6) * outputLineHeight.value).dp.coerceAtMost(maxOutputHeight)
LaunchedEffect(inputFieldVersion) {
focusRequester.requestFocus()
keyboardController?.show()
horizontalScroll.scrollTo(0)
}
LaunchedEffect(output.size) {
outputVerticalScroll.animateScrollTo(outputVerticalScroll.maxValue)
}
LaunchedEffect(output.size, commandInput.text) {
if (output.isNotEmpty() && commandInput.text.isEmpty()) {
horizontalScroll.scrollTo(0)
}
}
fun submitCommand() {
focusManager.clearFocus(force = true)
keyboardController?.hide()
onRun()
}
Box(
modifier = Modifier
.fillMaxSize()
.background(TerminalBackground, RoundedCornerShape(12.dp))
.padding(8.dp)
.horizontalScroll(horizontalScroll),
) {
Column(
modifier = Modifier
.fillMaxHeight()
.widthIn(min = terminalMinWidth),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.heightIn(min = 120.dp, max = maxOutputHeight)
.height(desiredOutputHeight)
.fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(10.dp))
.padding(8.dp)
.verticalScroll(outputVerticalScroll),
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
output.forEach { line ->
Text(
text = line,
color = if (line.startsWith("") || line.startsWith("🏁")) Success else TextSecondary,
fontFamily = FontFamily.Monospace,
)
}
}
}
SpecialKeyBar(
onTab = onTab,
onHelp = onHelp,
onCursorLeft = onCursorLeft,
onCursorRight = onCursorRight,
onHistoryUp = onHistoryUp,
onHistoryDown = onHistoryDown,
)
Row(
modifier = Modifier
.bringIntoViewRequester(bringIntoViewRequester)
.fillMaxWidth()
.widthIn(min = terminalMinWidth)
.background(PanelPrimary, RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp, vertical = 3.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "$",
color = Accent,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
)
TerminalInputField(
inputFieldVersion = inputFieldVersion,
commandInput = commandInput,
onValueChange = onValueChange,
focusRequester = focusRequester,
bringIntoViewRequester = bringIntoViewRequester,
keyboardController = keyboardController,
onSubmit = { submitCommand() },
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RowScope.TerminalInputField(
inputFieldVersion: Int,
commandInput: TextFieldValue,
onValueChange: (TextFieldValue) -> Unit,
focusRequester: FocusRequester,
bringIntoViewRequester: BringIntoViewRequester,
keyboardController: androidx.compose.ui.platform.SoftwareKeyboardController?,
onSubmit: () -> Unit,
) {
val scope = rememberCoroutineScope()
key(inputFieldVersion) {
BasicTextField(
value = commandInput,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.bringIntoViewRequester(bringIntoViewRequester)
.onFocusChanged { state ->
if (state.isFocused) {
keyboardController?.show()
scope.launch {
delay(150)
bringIntoViewRequester.bringIntoView()
}
}
},
singleLine = true,
textStyle = TextStyle(
color = TextPrimary,
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
fontWeight = FontWeight.Normal,
),
cursorBrush = SolidColor(Accent),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onSubmit() }),
decorationBox = { innerTextField ->
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.padding(vertical = 2.dp),
contentAlignment = Alignment.CenterStart,
) {
if (commandInput.text.isEmpty()) {
Text(
text = "Enter git command",
color = TextMuted.copy(alpha = 0.7f),
fontFamily = FontFamily.Monospace,
fontSize = 15.sp,
)
}
innerTextField()
}
},
)
}
}
@Composable
private fun SpecialKeyBar(
onTab: () -> Unit,
onHelp: () -> Unit,
onCursorLeft: () -> Unit,
onCursorRight: () -> Unit,
onHistoryUp: () -> Unit,
onHistoryDown: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TerminalKeyButton(label = "", onClick = onTab, modifier = Modifier.width(56.dp), fontFamily = FontFamily.Default)
TerminalKeyButton(label = "?", onClick = onHelp, modifier = Modifier.width(40.dp), fontFamily = FontFamily.Default)
TerminalKeyButton(label = "", onClick = onCursorLeft, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onCursorRight, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onHistoryUp, modifier = Modifier.width(40.dp))
TerminalKeyButton(label = "", onClick = onHistoryDown, modifier = Modifier.width(40.dp))
}
}
@Composable
private fun TerminalKeyButton(
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
fontFamily: FontFamily = FontFamily.Monospace,
) {
Button(
onClick = onClick,
modifier = modifier.height(28.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PanelSecondary,
contentColor = TextPrimary,
),
) {
Text(
text = label,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold,
fontSize = 11.sp,
)
}
}

View File

@@ -0,0 +1,103 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.layout.Arrangement
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.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@Composable
fun VisualPane(repo: RepoState) {
val configuration = LocalConfiguration.current
val isWide = configuration.screenWidthDp >= 840
if (isWide) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
InfoPaneCard(
title = "Workspace",
content = repo.files.joinToString("\n") {
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
}.ifBlank { "(empty)" },
modifier = Modifier.weight(1f),
)
InfoPaneCard(
title = "Branches",
content = buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(),
modifier = Modifier.weight(1f),
)
InfoPaneCard(
title = "Commits",
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
modifier = Modifier.weight(1f),
)
}
} else {
Column(
modifier = Modifier
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
InfoPaneCard(
title = "Workspace",
content = repo.files.joinToString("\n") {
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
}.ifBlank { "(empty)" },
)
InfoPaneCard(
title = "Branches",
content = buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(),
)
InfoPaneCard(
title = "Commits",
content = repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" },
)
}
}
}
@Composable
fun InfoPaneCard(
title: String,
content: String,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = PanelPrimary),
shape = RoundedCornerShape(10.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
Text(
content,
color = TextSecondary,
fontFamily = FontFamily.Monospace,
)
}
}
}