commit 8303cfdea63a92fb3a451bc585d3d40546aaf2b5 Author: Joe Tretter Date: Tue Apr 21 21:22:08 2026 -0500 Auto-commit after successful build (2026-04-21 21:22:08) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb40428 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +build/ +app/build/ +local.properties +.idea/ +android-sdk/ +jdk/ +.tmp-android-build/ +.setup-build-environment.state \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f12649e --- /dev/null +++ b/README.md @@ -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 \ No newline at end of file diff --git a/SetupBuildEnvironment.sh b/SetupBuildEnvironment.sh new file mode 100755 index 0000000..a086ac6 --- /dev/null +++ b/SetupBuildEnvironment.sh @@ -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 - <&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" </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 "$@" \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..1e8773b --- /dev/null +++ b/app/build.gradle.kts @@ -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") +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..200eb5c --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/GameModels.kt b/app/src/main/java/com/kawomi/githugandroid/GameModels.kt new file mode 100644 index 0000000..b3e0253 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/GameModels.kt @@ -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 = emptyList(), + val commits: List = emptyList(), + val headBranch: String = "master", + val branches: Map = emptyMap(), +) + +data class Level( + val id: String, + val title: String, + val description: String, + val hints: List, + val commandSuggestions: List, + val validator: (RepoState) -> Boolean, + val setup: () -> RepoState, +) + +fun sampleLevels(): List = 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( + 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> { + 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): Pair> { + 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 { + 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) + } + } + } + } +} \ 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 new file mode 100644 index 0000000..da38303 --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/GitHugApp.kt @@ -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()) } + 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, currentLevelIndex: Int, completedLevels: Set, 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, + 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() }) + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kawomi/githugandroid/MainActivity.kt b/app/src/main/java/com/kawomi/githugandroid/MainActivity.kt new file mode 100644 index 0000000..a72404a --- /dev/null +++ b/app/src/main/java/com/kawomi/githugandroid/MainActivity.kt @@ -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() + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d63eb73 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + GitHug Android + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..f4c79fb --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..c4cb680 --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..23e832f --- /dev/null +++ b/build.gradle.kts @@ -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 +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2db8823 --- /dev/null +++ b/gradle.properties @@ -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 \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..81c736b --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 \ No newline at end of file diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..df7009d --- /dev/null +++ b/gradlew @@ -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 "$@" \ No newline at end of file diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..0b497f8 --- /dev/null +++ b/gradlew.bat @@ -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 \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..8ff4c2a --- /dev/null +++ b/settings.gradle.kts @@ -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") \ No newline at end of file