Auto-commit after successful build (2026-04-21 21:22:08)

This commit is contained in:
Joe Tretter
2026-04-21 21:22:08 -05:00
commit 8303cfdea6
19 changed files with 1040 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.gradle/
build/
app/build/
local.properties
.idea/
android-sdk/
jdk/
.tmp-android-build/
.setup-build-environment.state

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# GitHug Android
Native Android adaptation of the GitHug learning game, built with **Kotlin + Jetpack Compose** and designed for **mobile-first usability** while preserving a **CLI-first play style**.
## Current MVP scope
This repository contains a starter Android app with:
- Kotlin + Jetpack Compose project scaffolding
- CLI-first gameplay shell
- Optional hideable visualization panel
- In-memory Git sandbox engine for early commands
- First playable GitHug-inspired levels: `init`, `add`, and `commit`
## Product direction
The app supports three modes:
- **CLI ONLY**: hide all visualization and play with terminal-style input only
- **HYBRID**: command line with optional repo panels
- **VISUAL**: repo panels visible by default, still backed by the same command engine
## Building
You will need a local Android SDK installation and either Android Studio or SDK command line tools.
Typical next step once the SDK is installed:
```bash
./gradlew assembleDebug
```
## Automated environment setup
This repo includes a root-level setup script:
```bash
bash ./SetupBuildEnvironment.sh
```
It is designed to be idempotent and uses project-relative paths to:
- install a project-local JDK into `./jdk`
- install Android command-line tools into `./android-sdk`
- restore `gradle/wrapper/gradle-wrapper.jar` if missing
- install required Android SDK packages
- write `local.properties`
To also build the debug APK without leaving Gradle running in the background:
```bash
bash ./SetupBuildEnvironment.sh --build
```
The script and project are configured to prefer non-daemon Gradle usage.
It also avoids depending on the system Java version by provisioning a project-local **JDK 17**, which is required by current Android SDK command-line tools.
To avoid repeated expensive SDK verification, the script writes a small state file named:
`./.setup-build-environment.state`
If all required components were verified successfully, the script will skip SDK verification/reinstallation for the next **24 hours** unless required directories are missing.
When you run:
```bash
bash ./SetupBuildEnvironment.sh --build
```
the script will also attempt to create a **git commit after a successful build** if there are source changes to commit.
## Next steps toward full GitHug parity
- Expand the Git sandbox to cover merge, rebase, stash, tags, remotes, reset, revert, cherry-pick, bisect, and more
- Port all original levels and hints into structured content files
- Add richer validation rules and per-level explanations
- Add onboarding, accessibility polish, icons, tests, and Play Store assets

255
SetupBuildEnvironment.sh Executable file
View File

@@ -0,0 +1,255 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$SCRIPT_DIR"
TMP_DIR="$PROJECT_DIR/.tmp-android-build"
SDK_DIR="$PROJECT_DIR/android-sdk"
JDK_DIR="$PROJECT_DIR/jdk"
STATE_FILE="$PROJECT_DIR/.setup-build-environment.state"
CMDLINE_TOOLS_DIR="$SDK_DIR/cmdline-tools"
CMDLINE_TOOLS_LATEST_DIR="$CMDLINE_TOOLS_DIR/latest"
WRAPPER_JAR_PATH="$PROJECT_DIR/gradle/wrapper/gradle-wrapper.jar"
LOCAL_PROPERTIES_PATH="$PROJECT_DIR/local.properties"
ANDROID_CMDLINE_TOOLS_URL="https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip"
GRADLE_DIST_URL="https://services.gradle.org/distributions/gradle-8.7-bin.zip"
JDK_DIST_URL="https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/eclipse"
STATE_TTL_SECONDS=86400
log() {
printf '\n[%s] %s\n' "setup" "$1"
}
require_tool() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required tool: $1" >&2
exit 1
fi
}
ensure_dir() {
mkdir -p "$1"
}
current_epoch() {
date +%s
}
have_recent_success_marker() {
if [ ! -f "$STATE_FILE" ]; then
return 1
fi
local recorded_epoch
recorded_epoch="$(cat "$STATE_FILE" 2>/dev/null || true)"
if ! printf '%s' "$recorded_epoch" | grep -Eq '^[0-9]+$'; then
return 1
fi
local now
now="$(current_epoch)"
[ $((now - recorded_epoch)) -lt "$STATE_TTL_SECONDS" ]
}
mark_successful_check() {
current_epoch > "$STATE_FILE"
}
auto_commit_if_needed() {
if ! command -v git >/dev/null 2>&1; then
log "Git is not available; skipping automatic commit"
return
fi
if ! git -C "$PROJECT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
log "Project is not inside a git work tree; skipping automatic commit"
return
fi
git -C "$PROJECT_DIR" add -A
if git -C "$PROJECT_DIR" diff --cached --quiet; then
log "No source changes to commit after successful build"
return
fi
local commit_message
commit_message="Auto-commit after successful build ($(date '+%Y-%m-%d %H:%M:%S'))"
log "Creating git commit for the latest successful build"
git -C "$PROJECT_DIR" commit -m "$commit_message"
}
download_if_missing() {
local url="$1"
local output="$2"
if [ -f "$output" ]; then
log "Using existing download: ${output#$PROJECT_DIR/}"
else
log "Downloading $(basename "$output")"
curl -L "$url" -o "$output"
fi
}
ensure_cmdline_tools() {
if [ -x "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" ]; then
log "Android command-line tools already present"
return
fi
ensure_dir "$CMDLINE_TOOLS_DIR"
ensure_dir "$TMP_DIR"
local zip_path="$TMP_DIR/commandlinetools.zip"
download_if_missing "$ANDROID_CMDLINE_TOOLS_URL" "$zip_path"
log "Extracting Android command-line tools"
rm -rf "$CMDLINE_TOOLS_LATEST_DIR"
unzip -q -o "$zip_path" -d "$CMDLINE_TOOLS_DIR"
if [ -d "$CMDLINE_TOOLS_DIR/cmdline-tools" ]; then
mv "$CMDLINE_TOOLS_DIR/cmdline-tools" "$CMDLINE_TOOLS_LATEST_DIR"
fi
}
ensure_wrapper_jar() {
if [ -f "$WRAPPER_JAR_PATH" ]; then
log "Gradle wrapper jar already present"
return
fi
ensure_dir "$TMP_DIR"
local zip_path="$TMP_DIR/gradle-8.7-bin.zip"
local gradle_extract_dir="$TMP_DIR/gradle-8.7"
local plugin_wrapper_jar="$gradle_extract_dir/lib/plugins/gradle-wrapper-8.7.jar"
download_if_missing "$GRADLE_DIST_URL" "$zip_path"
log "Extracting Gradle wrapper jar"
rm -rf "$gradle_extract_dir"
unzip -q -o "$zip_path" -d "$TMP_DIR"
python - <<PY
import zipfile
src = r'''$plugin_wrapper_jar'''
dst = r'''$WRAPPER_JAR_PATH'''
with zipfile.ZipFile(src) as zf:
data = zf.read('gradle-wrapper.jar')
with open(dst, 'wb') as f:
f.write(data)
PY
}
ensure_jdk17() {
if [ -x "$JDK_DIR/bin/java" ]; then
log "Project-local JDK already present"
return
fi
ensure_dir "$TMP_DIR"
local archive_path="$TMP_DIR/jdk17.tar.gz"
local extract_dir="$TMP_DIR/jdk17-extract"
rm -f "$archive_path"
download_if_missing "$JDK_DIST_URL" "$archive_path"
log "Extracting project-local JDK"
rm -rf "$extract_dir" "$JDK_DIR"
mkdir -p "$extract_dir"
if ! file "$archive_path" | grep -qiE 'gzip compressed|tar archive'; then
echo "Downloaded JDK archive is not a valid tar.gz: $archive_path" >&2
exit 1
fi
tar -xzf "$archive_path" -C "$extract_dir"
local extracted_root
extracted_root="$(find "$extract_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
if [ -z "$extracted_root" ]; then
echo "Unable to locate extracted JDK directory" >&2
exit 1
fi
mv "$extracted_root" "$JDK_DIR"
}
setup_java_env() {
export JAVA_HOME="$JDK_DIR"
export PATH="$JAVA_HOME/bin:$PATH"
}
write_local_properties() {
log "Writing local.properties"
cat > "$LOCAL_PROPERTIES_PATH" <<EOF_INNER
sdk.dir=$SDK_DIR
EOF_INNER
}
setup_env() {
setup_java_env
export ANDROID_HOME="$SDK_DIR"
export ANDROID_SDK_ROOT="$SDK_DIR"
}
ensure_sdk_packages() {
setup_env
if have_recent_success_marker \
&& [ -d "$SDK_DIR/platform-tools" ] \
&& [ -d "$SDK_DIR/platforms/android-34" ] \
&& [ -d "$SDK_DIR/build-tools/34.0.0" ]; then
log "Skipping SDK verification/install because all components were confirmed within the last 24 hours"
return
fi
log "Accepting Android SDK licenses"
set +e
set +o pipefail
yes | "$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" --licenses >/dev/null
local license_status=$?
set -o pipefail
set -e
if [ "$license_status" -ne 0 ]; then
echo "sdkmanager --licenses failed with exit code $license_status" >&2
exit "$license_status"
fi
log "Installing required Android SDK packages"
"$CMDLINE_TOOLS_LATEST_DIR/bin/sdkmanager" --sdk_root="$SDK_DIR" \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0"
mark_successful_check
}
maybe_build() {
if [ "${1:-}" != "--build" ]; then
return
fi
setup_env
log "Building debug APK with --no-daemon"
"$PROJECT_DIR/gradlew" --no-daemon assembleDebug
auto_commit_if_needed
log "Stopping any Gradle daemons just in case"
"$PROJECT_DIR/gradlew" --stop >/dev/null 2>&1 || true
}
main() {
require_tool curl
require_tool unzip
require_tool tar
ensure_dir "$TMP_DIR"
ensure_dir "$SDK_DIR"
ensure_jdk17
ensure_cmdline_tools
ensure_wrapper_jar
write_local_properties
ensure_sdk_packages
maybe_build "${1:-}"
log "Environment ready"
log "Project dir: $PROJECT_DIR"
log "JDK dir: $JDK_DIR"
log "SDK dir: $SDK_DIR"
log "To build without a background Gradle daemon: ./gradlew --no-daemon assembleDebug"
log "To setup and build in one step: bash ./SetupBuildEnvironment.sh --build"
}
main "$@"

78
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,78 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.kawomi.githugandroid"
compileSdk = 34
defaultConfig {
applicationId = "com.kawomi.githugandroid"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.14"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.3")
implementation("androidx.activity:activity-compose:1.9.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.3")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3:1.2.1")
implementation("com.google.android.material:material:1.12.0")
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

0
app/proguard-rules.pro vendored Normal file
View File

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_rules"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.GithugAndroid">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,174 @@
package com.kawomi.githugandroid
import androidx.compose.runtime.saveable.listSaver
enum class PlayMode(val label: String) {
CLI_ONLY("CLI ONLY"),
VISUAL("VISUAL"),
}
data class GitFile(
val name: String,
val content: String = "",
val staged: Boolean = false,
)
data class CommitNode(
val id: String,
val message: String,
)
data class RepoState(
val initialized: Boolean = false,
val files: List<GitFile> = emptyList(),
val commits: List<CommitNode> = emptyList(),
val headBranch: String = "master",
val branches: Map<String, Int> = emptyMap(),
)
data class Level(
val id: String,
val title: String,
val description: String,
val hints: List<String>,
val commandSuggestions: List<String>,
val validator: (RepoState) -> Boolean,
val setup: () -> RepoState,
)
fun sampleLevels(): List<Level> = listOf(
Level(
id = "init",
title = "Init",
description = "A new directory, git_hug, has been created. Initialize an empty repository in it.",
hints = listOf("Use git init to create a new repository.", "Try `git init` in the command area."),
commandSuggestions = listOf("git init", "git status"),
validator = { it.initialized },
setup = { RepoState() },
),
Level(
id = "add",
title = "Add",
description = "There is a file in your folder called README; add it to your staging area.",
hints = listOf("You want to stage README.", "Use `git add README`."),
commandSuggestions = listOf("git status", "git add README", "ls"),
validator = { repo -> repo.files.any { it.name == "README" && it.staged } },
setup = { RepoState(initialized = true, files = listOf(GitFile("README")), branches = mapOf("master" to 0)) },
),
Level(
id = "commit",
title = "Commit",
description = "The README file has been added to your staging area, now commit it.",
hints = listOf("You must include a message when you commit.", "Use `git commit -m \"message\"`."),
commandSuggestions = listOf("git status", "git commit -m \"Initial commit\"", "git log"),
validator = { repo -> repo.commits.isNotEmpty() },
setup = { RepoState(initialized = true, files = listOf(GitFile("README", staged = true)), branches = mapOf("master" to 0)) },
),
)
val RepoStateSaver = listSaver<RepoState, Any>(
save = { state ->
listOf(
state.initialized,
state.headBranch,
state.files.flatMap { listOf(it.name, it.content, it.staged.toString()) },
state.commits.flatMap { listOf(it.id, it.message) },
state.branches.flatMap { listOf(it.key, it.value.toString()) },
)
},
restore = { saved ->
val initialized = saved[0] as Boolean
val headBranch = saved[1] as String
val fileParts = saved[2] as List<*>
val commitParts = saved[3] as List<*>
val branchParts = saved[4] as List<*>
RepoState(
initialized = initialized,
headBranch = headBranch,
files = fileParts.chunked(3).map {
GitFile(it[0] as String, it[1] as String, (it[2] as String).toBoolean())
},
commits = commitParts.chunked(2).map {
CommitNode(it[0] as String, it[1] as String)
},
branches = branchParts.chunked(2).associate { (it[0] as String) to (it[1] as String).toInt() }
)
}
)
object GitSandboxEngine {
fun execute(repo: RepoState, command: String): Pair<RepoState, List<String>> {
val parts = command.split(" ").filter { it.isNotBlank() }
if (parts.isEmpty()) return repo to emptyList()
return when {
parts[0] == "touch" && parts.size >= 2 -> {
val name = parts[1]
if (repo.files.any { it.name == name }) repo to listOf("$name already exists")
else repo.copy(files = repo.files + GitFile(name = name)) to emptyList()
}
parts[0] == "ls" -> repo to repo.files.map { it.name }.ifEmpty { listOf() }
parts[0] != "git" -> repo to listOf("Command not supported in sandbox. Try a git command or 'touch'.")
parts.size >= 2 && parts[1] == "init" -> repo.copy(initialized = true, branches = mapOf("master" to repo.commits.size)) to listOf("Initialized empty Git repository")
!repo.initialized -> repo to listOf("fatal: not a git repository")
parts.size >= 2 && parts[1] == "status" -> repo to statusLines(repo)
parts.size >= 3 && parts[1] == "add" -> {
val target = parts[2]
val updated = repo.files.map { if (target == "." || it.name == target) it.copy(staged = true) else it }
repo.copy(files = updated) to listOf("staged ${if (target == ".") "all files" else target}")
}
parts.size >= 2 && parts[1] == "commit" -> commit(repo, parts)
parts.size >= 2 && parts[1] == "log" -> {
repo to if (repo.commits.isEmpty()) listOf("fatal: your current branch '${repo.headBranch}' does not have any commits yet")
else repo.commits.reversed().flatMap { listOf("commit ${it.id}", " ${it.message}") }
}
parts.size >= 3 && parts[1] == "branch" -> {
val branch = parts[2]
if (repo.branches.containsKey(branch)) repo to listOf("fatal: a branch named '$branch' already exists")
else repo.copy(branches = repo.branches + (branch to repo.commits.size)) to listOf("Created branch $branch")
}
parts.size >= 3 && parts[1] == "checkout" -> {
val branch = parts[2]
if (!repo.branches.containsKey(branch)) repo to listOf("error: pathspec '$branch' did not match any branch")
else repo.copy(headBranch = branch) to listOf("Switched to branch '$branch'")
}
else -> repo to listOf("Unsupported git command in MVP sandbox: ${parts.drop(1).joinToString(" ")}")
}
}
private fun commit(repo: RepoState, parts: List<String>): Pair<RepoState, List<String>> {
val messageIndex = parts.indexOf("-m")
if (messageIndex == -1 || messageIndex == parts.lastIndex) {
return repo to listOf("error: commit message required. Use git commit -m \"message\"")
}
val message = parts.drop(messageIndex + 1).joinToString(" ").trim('"')
val staged = repo.files.filter { it.staged }
if (staged.isEmpty()) return repo to listOf("nothing to commit")
val nextId = "${repo.commits.size + 1}".padStart(7, '0')
val cleanedFiles = repo.files.map { it.copy(staged = false) }
return repo.copy(
files = cleanedFiles,
commits = repo.commits + CommitNode(nextId, message),
branches = repo.branches + (repo.headBranch to (repo.commits.size + 1)),
) to listOf("[$nextId] $message")
}
private fun statusLines(repo: RepoState): List<String> {
val staged = repo.files.filter { it.staged }.map { "new file: ${it.name}" }
val unstaged = repo.files.filterNot { it.staged }.map { "untracked: ${it.name}" }
return buildList {
add("On branch ${repo.headBranch}")
if (staged.isEmpty() && unstaged.isEmpty()) {
add("nothing to commit, working tree clean")
} else {
if (staged.isNotEmpty()) {
add("Changes to be committed:")
addAll(staged)
}
if (unstaged.isNotEmpty()) {
add("Untracked files:")
addAll(unstaged)
}
}
}
}
}

View File

@@ -0,0 +1,325 @@
package com.kawomi.githugandroid
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
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.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.Color
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.TextStyle
import androidx.compose.ui.unit.dp
private val AppBackground = Color(0xFF000000)
private val PanelPrimary = Color(0xFF121212)
private val PanelSecondary = Color(0xFF1E1E1E)
private val PanelTertiary = Color(0xFF262626)
private val TerminalBackground = Color(0xFF050505)
private val TextPrimary = Color(0xFFFFFFFF)
private val TextSecondary = Color(0xFFE6E6E6)
private val TextMuted = Color(0xFFBDBDBD)
private val Accent = Color(0xFF00E5FF)
private val Success = Color(0xFF00FF95)
private val GitHugColorScheme = darkColorScheme(
primary = Accent,
onPrimary = Color.Black,
secondary = TextPrimary,
onSecondary = Color.Black,
background = AppBackground,
onBackground = TextPrimary,
surface = PanelPrimary,
onSurface = TextPrimary,
surfaceVariant = PanelSecondary,
onSurfaceVariant = TextSecondary,
outline = TextMuted,
)
@Composable
fun GitHugApp() {
MaterialTheme(colorScheme = GitHugColorScheme) {
val levels = remember { sampleLevels() }
val screenScrollState = rememberScrollState()
var currentLevelIndex by remember { mutableStateOf(0) }
var mode by remember { mutableStateOf(PlayMode.CLI_ONLY) }
var repo by remember { mutableStateOf(levels.first().setup()) }
var commandInput by remember { mutableStateOf("") }
var output by remember { mutableStateOf(listOf("Welcome to GitHug Android.")) }
var hintIndex by remember { mutableStateOf(0) }
var completedLevels by remember { mutableStateOf(setOf<String>()) }
val currentLevel = levels[currentLevelIndex]
val solved = currentLevel.validator(repo)
LaunchedEffect(currentLevelIndex) {
screenScrollState.scrollTo(0)
}
fun resetCurrentLevel(message: String = "Level reset.") {
repo = currentLevel.setup()
commandInput = ""
output = listOf(message)
hintIndex = 0
}
fun runCommand() {
val raw = commandInput.trim()
if (raw.isBlank()) return
val (newRepo, lines) = GitSandboxEngine.execute(repo, raw)
val solvedAfterCommand = currentLevel.validator(newRepo)
val wasAlreadyCompleted = currentLevel.id in completedLevels
val newOutput = buildList {
addAll(output)
add("$ $raw")
addAll(lines)
if (solvedAfterCommand && !wasAlreadyCompleted) {
add("✔ Level solved: ${currentLevel.title}")
}
}
if (solvedAfterCommand && !wasAlreadyCompleted) {
completedLevels = completedLevels + currentLevel.id
val hasNextLevel = currentLevelIndex < levels.lastIndex
if (hasNextLevel) {
val nextLevelIndex = currentLevelIndex + 1
val nextLevel = levels[nextLevelIndex]
currentLevelIndex = nextLevelIndex
repo = nextLevel.setup()
output = listOf("Loaded level: ${nextLevel.title}")
commandInput = ""
hintIndex = 0
} else {
repo = newRepo
output = listOf("🏁 All available MVP levels completed.")
commandInput = ""
}
} else {
repo = newRepo
output = newOutput
commandInput = ""
}
}
Scaffold { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(AppBackground)
.verticalScroll(screenScrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Header(levels, currentLevelIndex, completedLevels) { index ->
currentLevelIndex = index
repo = levels[index].setup()
output = listOf("Loaded level: ${levels[index].title}")
commandInput = ""
hintIndex = 0
}
ModeBar(mode = mode, onModeSelected = { mode = it })
LevelCard(
level = currentLevel,
onHint = {
val hint = currentLevel.hints.getOrNull(hintIndex) ?: "No more hints for this level."
output = output + "hint> $hint"
hintIndex = (hintIndex + 1).coerceAtMost(currentLevel.hints.size)
},
onReset = { resetCurrentLevel() },
)
if (mode == PlayMode.VISUAL) {
VisualizationPanel(repo)
}
TerminalPanel(
output = output,
commandInput = commandInput,
onValueChange = { commandInput = it },
onRun = { runCommand() },
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 260.dp)
)
}
}
}
}
}
@Composable
private fun Header(levels: List<Level>, currentLevelIndex: Int, completedLevels: Set<String>, onSelect: (Int) -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = PanelPrimary)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("GitHug Android", style = MaterialTheme.typography.headlineSmall, color = TextPrimary)
Text("CLI-first Git learning on Android, with optional repository visualization.", color = TextSecondary)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
levels.forEachIndexed { index, level ->
TextButton(
onClick = { onSelect(index) },
colors = ButtonDefaults.textButtonColors(
contentColor = if (index == currentLevelIndex) Accent else TextSecondary
)
) {
Text(
text = buildString {
append(if (level.id in completedLevels) "" else "")
append(level.title)
}
)
}
}
}
}
}
}
@Composable
private fun ModeBar(
mode: PlayMode,
onModeSelected: (PlayMode) -> Unit,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
PlayMode.entries.forEach {
FilterChip(
selected = mode == it,
onClick = { onModeSelected(it) },
label = { Text(it.label) },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Accent,
selectedLabelColor = Color.Black,
containerColor = PanelSecondary,
labelColor = TextPrimary
)
)
}
}
}
@Composable
private fun LevelCard(level: Level, onHint: () -> Unit, onReset: () -> Unit) {
Card(colors = CardDefaults.cardColors(containerColor = PanelSecondary)) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.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 = onReset,
colors = ButtonDefaults.textButtonColors(contentColor = Accent)
) { Text("Reset level") }
}
}
}
}
@Composable
private fun VisualizationPanel(repo: RepoState) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth()) {
InfoCard("Workspace", repo.files.joinToString("\n") {
"${if (it.staged) "[staged]" else "[file] "} ${it.name}"
}.ifBlank { "(empty)" }, Modifier.weight(1f))
InfoCard("Branches", buildString {
appendLine("HEAD -> ${repo.headBranch}")
repo.branches.forEach { (name, _) -> appendLine(name) }
}.trim(), Modifier.weight(1f))
InfoCard("Commits", repo.commits.reversed().joinToString("\n") { "${it.id} ${it.message}" }.ifBlank { "No commits yet" }, Modifier.weight(1f))
}
}
@Composable
private fun InfoCard(title: String, content: String, modifier: Modifier = Modifier) {
Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = PanelSecondary), shape = RoundedCornerShape(16.dp)) {
Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, color = TextPrimary, fontWeight = FontWeight.Bold)
Text(content, color = TextSecondary, fontFamily = FontFamily.Monospace)
}
}
}
@Composable
private fun TerminalPanel(
output: List<String>,
commandInput: String,
onValueChange: (String) -> Unit,
onRun: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier, colors = CardDefaults.cardColors(containerColor = TerminalBackground)) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("Terminal", color = TextPrimary, fontWeight = FontWeight.Bold)
output.forEach { line ->
Text(
text = line,
color = if (line.startsWith("") || line.startsWith("🏁")) Success else TextSecondary,
fontFamily = FontFamily.Monospace
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.background(PanelPrimary, RoundedCornerShape(8.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "$",
color = Accent,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = commandInput,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
textStyle = TextStyle(color = TextPrimary, fontFamily = FontFamily.Monospace),
cursorBrush = SolidColor(Accent),
keyboardOptions = KeyboardOptions(
autoCorrect = false,
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { onRun() })
)
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
package com.kawomi.githugandroid
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
GitHugApp()
}
}
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">GitHug Android</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.GithugAndroid" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/black</item>
<item name="android:windowLightStatusBar" tools:targetApi="m">false</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="." />
</full-backup-content>

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}

5
gradle.properties Normal file
View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
org.gradle.daemon=false
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

12
gradlew vendored Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
APP_HOME=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
if [ -n "$JAVA_HOME" ] ; then
JAVACMD="$JAVA_HOME/bin/java"
else
JAVACMD=java
fi
exec "$JAVACMD" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"

23
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,23 @@
@ECHO OFF
SET DIRNAME=%~dp0
IF "%DIRNAME%"=="" SET DIRNAME=.
SET APP_HOME=%DIRNAME%
SET CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
IF DEFINED JAVA_HOME GOTO findJavaFromJavaHome
SET JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
IF %ERRORLEVEL% EQU 0 GOTO execute
ECHO ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
GOTO fail
:findJavaFromJavaHome
SET JAVA_HOME=%JAVA_HOME:"=%
SET JAVA_EXE=%JAVA_HOME%/bin/java.exe
:execute
"%JAVA_EXE%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:fail
EXIT /B 1

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "Githug-Android"
include(":app")