diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6d30c66..c69f759 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.kawomi.githugandroid" minSdk = 26 targetSdk = 34 - versionCode = 34 - versionName = "0.1.33" + versionCode = 38 + versionName = "0.1.37" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/app/src/main/java/com/kawomi/githugandroid/Exercise.kt b/app/src/main/java/com/kawomi/githugandroid/Exercise.kt new file mode 100644 index 0000000..f8a8d5d --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/Exercise.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt index dc616d8..b9cc8b1 100644 --- a/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt @@ -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, - currentLevelIndex: Int, - completedLevels: Set, - 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, - 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, - ) - } -} - diff --git a/app/src/main/java/com/kawomi/githugandroid/Header.kt b/app/src/main/java/com/kawomi/githugandroid/Header.kt new file mode 100644 index 0000000..9f2f0ee --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/Header.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/Levels.kt b/app/src/main/java/com/kawomi/githugandroid/Levels.kt new file mode 100644 index 0000000..ec7896d --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/Levels.kt @@ -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, + currentLevelIndex: Int, + completedLevels: Set, + 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(), + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/Panes.kt b/app/src/main/java/com/kawomi/githugandroid/Panes.kt new file mode 100644 index 0000000..178b8ad --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/Panes.kt @@ -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, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/Terminal.kt b/app/src/main/java/com/kawomi/githugandroid/Terminal.kt new file mode 100644 index 0000000..7c1f3c5 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/Terminal.kt @@ -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, + 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, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/Visual.kt b/app/src/main/java/com/kawomi/githugandroid/Visual.kt new file mode 100644 index 0000000..a2bcabc --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/Visual.kt @@ -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, + ) + } + } +} \ No newline at end of file