42c14fae59
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
532 lines
16 KiB
Bash
Executable File
532 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# init_kotlin_app — Scaffolder canonico de apps Android Kotlin Compose del registry.
|
|
#
|
|
# Genera la estructura canonica (MainActivity.kt, build.gradle.kts, app.md,
|
|
# Roborazzi screenshot tests), apuntando al composite build kotlin/functions/ui
|
|
# para FnTheme + FnTokens, inicializa git + repo Gitea dataforge/<name>.
|
|
#
|
|
# Uso:
|
|
# init_kotlin_app <name> [--project <p>] [--desc "..."] [--tags "a,b"] [--package <com.foo.bar>]
|
|
#
|
|
# Por defecto sin proyecto (apps/<name>/), package = com.fnregistry.<name>.
|
|
|
|
set -euo pipefail
|
|
|
|
# Carga helpers del registry
|
|
FN_ROOT="${FN_REGISTRY_ROOT:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
# shellcheck source=/dev/null
|
|
source "$FN_ROOT/bash/functions/infra/ensure_repo_synced.sh"
|
|
|
|
init_kotlin_app() {
|
|
local name=""
|
|
local project=""
|
|
local desc=""
|
|
local tags=""
|
|
local pkg_id=""
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--project) project="$2"; shift 2 ;;
|
|
--desc) desc="$2"; shift 2 ;;
|
|
--tags) tags="$2"; shift 2 ;;
|
|
--package) pkg_id="$2"; shift 2 ;;
|
|
-*) echo "init_kotlin_app: flag desconocido: $1" >&2; return 2 ;;
|
|
*) if [[ -z "$name" ]]; then name="$1"; else
|
|
echo "init_kotlin_app: argumento extra: $1" >&2; return 2
|
|
fi
|
|
shift ;;
|
|
esac
|
|
done
|
|
|
|
if [[ -z "$name" ]]; then
|
|
echo "init_kotlin_app: se requiere <name>" >&2
|
|
echo "Uso: init_kotlin_app <name> [--project <p>] [--desc \"...\"] [--tags \"a,b\"] [--package <com.foo.bar>]" >&2
|
|
return 2
|
|
fi
|
|
|
|
# Validar snake_case
|
|
if [[ ! "$name" =~ ^[a-z][a-z0-9_]*$ ]]; then
|
|
echo "init_kotlin_app: nombre '$name' debe ser snake_case (solo letras minusculas, digitos y _)" >&2
|
|
return 2
|
|
fi
|
|
|
|
[[ -z "$desc" ]] && desc="App Android Kotlin Compose"
|
|
[[ -z "$pkg_id" ]] && pkg_id="com.fnregistry.$name"
|
|
|
|
# Resolver dir destino
|
|
local rel_dir abs_dir
|
|
if [[ -n "$project" ]]; then
|
|
if [[ ! -f "$FN_ROOT/projects/$project/project.md" ]]; then
|
|
echo "init_kotlin_app: proyecto '$project' no existe (falta projects/$project/project.md)" >&2
|
|
return 1
|
|
fi
|
|
rel_dir="projects/$project/apps/$name"
|
|
else
|
|
rel_dir="apps/$name"
|
|
fi
|
|
abs_dir="$FN_ROOT/$rel_dir"
|
|
|
|
if [[ -e "$abs_dir" ]]; then
|
|
echo "init_kotlin_app: $rel_dir ya existe" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Convertir package id a path (com.fnregistry.my_app -> com/fnregistry/my_app)
|
|
local pkg_path
|
|
pkg_path="$(echo "$pkg_id" | tr '.' '/')"
|
|
|
|
# Calcular path relativo al composite build de kotlin/functions/ui
|
|
# Desde apps/<name>/ -> ../../kotlin/functions/ui
|
|
# Desde projects/<p>/apps/<n> -> ../../../../kotlin/functions/ui
|
|
local ui_rel_path
|
|
if [[ -n "$project" ]]; then
|
|
ui_rel_path="../../../../kotlin/functions/ui"
|
|
else
|
|
ui_rel_path="../../kotlin/functions/ui"
|
|
fi
|
|
|
|
# ---- Crear estructura de directorios ----
|
|
mkdir -p "$abs_dir/app/src/main/kotlin/$pkg_path"
|
|
mkdir -p "$abs_dir/app/src/main/res/values"
|
|
mkdir -p "$abs_dir/app/src/test/kotlin/$pkg_path"
|
|
mkdir -p "$abs_dir/app/src/androidTest/kotlin/$pkg_path"
|
|
mkdir -p "$abs_dir/gradle/wrapper"
|
|
|
|
echo "init_kotlin_app: creando $rel_dir ..."
|
|
|
|
# ---- settings.gradle.kts ----
|
|
cat > "$abs_dir/settings.gradle.kts" <<EOF
|
|
pluginManagement {
|
|
repositories {
|
|
gradlePluginPortal()
|
|
google()
|
|
mavenCentral()
|
|
}
|
|
}
|
|
dependencyResolutionManagement {
|
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
|
repositories {
|
|
google()
|
|
mavenCentral()
|
|
}
|
|
}
|
|
rootProject.name = "$name"
|
|
include(":app")
|
|
|
|
// Composite build: FnTheme + FnTokens desde el registry
|
|
includeBuild("$ui_rel_path") {
|
|
dependencySubstitution {
|
|
substitute(module("fn.compose:ui")).using(project(":"))
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# ---- build.gradle.kts (raiz) ----
|
|
cat > "$abs_dir/build.gradle.kts" <<'EOF'
|
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
|
plugins {
|
|
id("com.android.application") version "8.4.0" apply false
|
|
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
|
}
|
|
EOF
|
|
|
|
# ---- app/build.gradle.kts ----
|
|
cat > "$abs_dir/app/build.gradle.kts" <<EOF
|
|
plugins {
|
|
id("com.android.application") version "8.4.0"
|
|
id("org.jetbrains.kotlin.android") version "1.9.22"
|
|
id("io.github.takahirom.roborazzi") version "1.20.0"
|
|
}
|
|
|
|
android {
|
|
namespace = "$pkg_id"
|
|
compileSdk = 34
|
|
|
|
defaultConfig {
|
|
applicationId = "$pkg_id"
|
|
minSdk = 24
|
|
targetSdk = 34
|
|
versionCode = 1
|
|
versionName = "0.1.0"
|
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
}
|
|
|
|
buildFeatures {
|
|
compose = true
|
|
}
|
|
composeOptions {
|
|
kotlinCompilerExtensionVersion = "1.5.8"
|
|
}
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
}
|
|
kotlinOptions {
|
|
jvmTarget = "17"
|
|
}
|
|
testOptions {
|
|
unitTests {
|
|
isIncludeAndroidResources = true
|
|
all {
|
|
it.systemProperty("robolectric.graphicsMode", "NATIVE")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
implementation("androidx.activity:activity-compose:1.8.2")
|
|
implementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
|
implementation("androidx.compose.ui:ui")
|
|
implementation("androidx.compose.material3:material3")
|
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
|
// FnTheme + FnTokens via composite build
|
|
implementation("fn.compose:ui")
|
|
|
|
testImplementation("junit:junit:4.13.2")
|
|
testImplementation("org.robolectric:robolectric:4.11.1")
|
|
testImplementation("androidx.compose.ui:ui-test-junit4")
|
|
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.20.0")
|
|
testImplementation("io.github.takahirom.roborazzi:roborazzi-compose:1.20.0")
|
|
testImplementation("io.github.takahirom.roborazzi:roborazzi-junit-rule:1.20.0")
|
|
androidTestImplementation(platform("androidx.compose:compose-bom:2024.02.00"))
|
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
|
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
|
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
|
}
|
|
EOF
|
|
|
|
# ---- gradle.properties ----
|
|
cat > "$abs_dir/gradle.properties" <<'EOF'
|
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
|
android.useAndroidX=true
|
|
kotlin.code.style=official
|
|
android.nonTransitiveRClass=true
|
|
EOF
|
|
|
|
# ---- gradle/wrapper/gradle-wrapper.properties ----
|
|
cat > "$abs_dir/gradle/wrapper/gradle-wrapper.properties" <<'EOF'
|
|
distributionBase=GRADLE_USER_HOME
|
|
distributionPath=wrapper/dists
|
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
|
networkTimeout=10000
|
|
validateDistributionUrl=true
|
|
zipStoreBase=GRADLE_USER_HOME
|
|
zipStorePath=wrapper/dists
|
|
EOF
|
|
|
|
# ---- gradlew + wrapper jar (vendored real wrapper) ----
|
|
local tmpl_wrapper="$FN_ROOT/bash/functions/pipelines/templates/kotlin/wrapper"
|
|
if [[ -f "$tmpl_wrapper/gradlew" && -f "$tmpl_wrapper/gradle-wrapper.jar" ]]; then
|
|
cp "$tmpl_wrapper/gradlew" "$abs_dir/gradlew"
|
|
cp "$tmpl_wrapper/gradle-wrapper.jar" "$abs_dir/gradle/wrapper/gradle-wrapper.jar"
|
|
chmod +x "$abs_dir/gradlew"
|
|
else
|
|
echo "init_kotlin_app: WARN templates/kotlin/wrapper missing, fallback gradlew stub"
|
|
cat > "$abs_dir/gradlew" <<'EOF'
|
|
#!/usr/bin/env bash
|
|
echo "gradlew stub — install gradle wrapper or replace with real one" >&2
|
|
exit 2
|
|
EOF
|
|
chmod +x "$abs_dir/gradlew"
|
|
fi
|
|
|
|
# ---- local.properties (Android SDK location, gitignored, per-machine) ----
|
|
local sdk_path="${ANDROID_SDK_DIR:-$HOME/android-sdk}"
|
|
if [[ ! -d "$sdk_path" ]] && [[ -d "$HOME/Android/Sdk" ]]; then
|
|
sdk_path="$HOME/Android/Sdk"
|
|
fi
|
|
cat > "$abs_dir/local.properties" <<EOF
|
|
# Auto-generated by init_kotlin_app. Per-machine, gitignored.
|
|
sdk.dir=$sdk_path
|
|
EOF
|
|
|
|
# ---- AndroidManifest.xml ----
|
|
cat > "$abs_dir/app/src/main/AndroidManifest.xml" <<EOF
|
|
<?xml version="1.0" encoding="utf-8"?>
|
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
|
|
<application
|
|
android:allowBackup="true"
|
|
android:label="@string/app_name"
|
|
android:supportsRtl="true"
|
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
|
|
|
<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>
|
|
EOF
|
|
|
|
# ---- res/values/strings.xml ----
|
|
cat > "$abs_dir/app/src/main/res/values/strings.xml" <<EOF
|
|
<resources>
|
|
<string name="app_name">$name</string>
|
|
</resources>
|
|
EOF
|
|
|
|
# ---- MainActivity.kt ----
|
|
cat > "$abs_dir/app/src/main/kotlin/$pkg_path/MainActivity.kt" <<EOF
|
|
package $pkg_id
|
|
|
|
import android.os.Bundle
|
|
import androidx.activity.ComponentActivity
|
|
import androidx.activity.compose.setContent
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Modifier
|
|
import fn.compose.theme.FnTheme
|
|
|
|
class MainActivity : ComponentActivity() {
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
setContent {
|
|
FnTheme {
|
|
Surface(modifier = Modifier.fillMaxSize()) {
|
|
Text("$name ready")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# ---- ExampleScreenshotTest.kt (Roborazzi) ----
|
|
cat > "$abs_dir/app/src/test/kotlin/$pkg_path/ExampleScreenshotTest.kt" <<EOF
|
|
package $pkg_id
|
|
|
|
import androidx.compose.material3.Surface
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.ui.test.junit4.createComposeRule
|
|
import androidx.compose.ui.test.onRoot
|
|
import com.github.takahirom.roborazzi.captureRoboImage
|
|
import fn.compose.theme.FnTheme
|
|
import org.junit.Rule
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
import org.robolectric.RobolectricTestRunner
|
|
import org.robolectric.annotation.GraphicsMode
|
|
|
|
@RunWith(RobolectricTestRunner::class)
|
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
|
class ExampleScreenshotTest {
|
|
|
|
@get:Rule
|
|
val composeTestRule = createComposeRule()
|
|
|
|
@Test
|
|
fun screenshotFnThemeSurface() {
|
|
composeTestRule.setContent {
|
|
FnTheme {
|
|
Surface {
|
|
Text("$name screenshot")
|
|
}
|
|
}
|
|
}
|
|
composeTestRule.onRoot().captureRoboImage("src/test/snapshots/images/${name}_smoke.png")
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# ---- MainActivityTest.kt (instrumented) ----
|
|
cat > "$abs_dir/app/src/androidTest/kotlin/$pkg_path/MainActivityTest.kt" <<EOF
|
|
package $pkg_id
|
|
|
|
import androidx.compose.ui.test.assertIsDisplayed
|
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
import androidx.compose.ui.test.onNodeWithText
|
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
import org.junit.Rule
|
|
import org.junit.Test
|
|
import org.junit.runner.RunWith
|
|
|
|
@RunWith(AndroidJUnit4::class)
|
|
class MainActivityTest {
|
|
|
|
@get:Rule
|
|
val composeTestRule = createAndroidComposeRule<MainActivity>()
|
|
|
|
@Test
|
|
fun appLaunchesAndShowsReadyText() {
|
|
composeTestRule
|
|
.onNodeWithText("$name ready")
|
|
.assertIsDisplayed()
|
|
}
|
|
}
|
|
EOF
|
|
|
|
# ---- app.md frontmatter ----
|
|
local repo_url="https://gitea.organic-machine.com/dataforge/$name"
|
|
local tags_yaml="[kotlin, compose, android]"
|
|
if [[ -n "$tags" ]]; then
|
|
tags_yaml="[$(echo "$tags" | sed 's/,/, /g'), kotlin, compose, android]"
|
|
fi
|
|
|
|
cat > "$abs_dir/app.md" <<EOF
|
|
---
|
|
name: $name
|
|
domain: tools
|
|
description: "$desc"
|
|
tags: $tags_yaml
|
|
lang: kt
|
|
framework: compose
|
|
entry_point: "app/src/main/kotlin/$pkg_path/MainActivity.kt"
|
|
dir_path: "$rel_dir"
|
|
repo_url: "$repo_url"
|
|
uses_functions:
|
|
- fn_theme_kt_ui
|
|
- fn_tokens_kt_ui
|
|
uses_types: []
|
|
e2e_checks:
|
|
- id: unit
|
|
cmd: "fn run gradle_unit_test_bash_infra $rel_dir"
|
|
timeout_s: 240
|
|
- id: screenshot
|
|
cmd: "fn run gradle_screenshot_test_bash_infra $rel_dir"
|
|
timeout_s: 240
|
|
- id: build
|
|
cmd: "fn run gradle_assemble_debug_bash_infra $rel_dir"
|
|
timeout_s: 360
|
|
- id: emu_start
|
|
cmd: "fn run android_emulator_start_bash_infra Medium_Phone_API_35"
|
|
timeout_s: 240
|
|
- id: instrumented
|
|
cmd: "fn run gradle_instrumented_test_bash_infra $rel_dir"
|
|
timeout_s: 600
|
|
- id: emu_stop
|
|
cmd: "fn run android_emulator_stop_bash_infra"
|
|
severity: warning
|
|
timeout_s: 30
|
|
---
|
|
|
|
# $name
|
|
|
|
$desc
|
|
|
|
## Build
|
|
|
|
\`\`\`bash
|
|
fn run gradle_assemble_debug_bash_infra $rel_dir
|
|
\`\`\`
|
|
|
|
## Tests unitarios + Roborazzi screenshots
|
|
|
|
\`\`\`bash
|
|
fn run gradle_unit_test_bash_infra $rel_dir
|
|
fn run gradle_screenshot_test_bash_infra $rel_dir
|
|
\`\`\`
|
|
|
|
## Tests instrumentados (requiere emulador)
|
|
|
|
\`\`\`bash
|
|
fn run android_emulator_start_bash_infra Medium_Phone_API_35
|
|
fn run gradle_instrumented_test_bash_infra $rel_dir
|
|
fn run android_emulator_stop_bash_infra
|
|
\`\`\`
|
|
|
|
## Package
|
|
|
|
\`$pkg_id\`
|
|
|
|
## FnTheme
|
|
|
|
La app usa FnTheme y FnTokens via composite build en \`kotlin/functions/ui\`.
|
|
Para cambiar colores o tipografia, editar el modulo del registry.
|
|
EOF
|
|
|
|
# ---- .gitignore ----
|
|
cat > "$abs_dir/.gitignore" <<'EOF'
|
|
.gradle/
|
|
build/
|
|
local.properties
|
|
*.iml
|
|
.idea/
|
|
captures/
|
|
.externalNativeBuild/
|
|
.cxx/
|
|
*.apk
|
|
*.aab
|
|
# NOTE: app/src/test/snapshots/ is committed (Roborazzi goldens are test refs).
|
|
EOF
|
|
|
|
# ---- README.md ----
|
|
cat > "$abs_dir/README.md" <<EOF
|
|
# $name
|
|
|
|
$desc
|
|
|
|
Generado con \`init_kotlin_app\` del registry fn_registry.
|
|
|
|
## Requisitos
|
|
|
|
- Android SDK 34
|
|
- JDK 17
|
|
- Gradle 8.6 (via wrapper)
|
|
- \`kotlin/functions/ui\` modulo del registry (composite build)
|
|
|
|
## Build rapido
|
|
|
|
\`\`\`bash
|
|
fn run gradle_assemble_debug_bash_infra $rel_dir
|
|
\`\`\`
|
|
EOF
|
|
|
|
# ---- Git + Gitea ----
|
|
if [[ -n "${GITEA_URL:-}" && -n "${GITEA_TOKEN:-}" ]]; then
|
|
ensure_repo_synced "$abs_dir" "dataforge" "$name" "master" \
|
|
"feat: scaffold $name via init_kotlin_app" || \
|
|
echo "init_kotlin_app: warning — ensure_repo_synced fallo, repo no creado" >&2
|
|
else
|
|
echo "init_kotlin_app: GITEA_URL/GITEA_TOKEN no seteados, omitiendo creacion de repo Gitea" >&2
|
|
(cd "$abs_dir" && git init -b master >/dev/null 2>&1 || git init >/dev/null 2>&1 \
|
|
&& git add -A \
|
|
&& git commit -m "feat: scaffold $name via init_kotlin_app" --quiet)
|
|
fi
|
|
|
|
# ---- fn index si hay proyecto ----
|
|
if [[ -n "$project" && -x "$FN_ROOT/fn" ]]; then
|
|
(cd "$FN_ROOT" && ./fn index >/dev/null 2>&1) || \
|
|
echo "init_kotlin_app: warning — fn index fallo" >&2
|
|
fi
|
|
|
|
echo ""
|
|
echo "init_kotlin_app: $rel_dir creada"
|
|
echo ""
|
|
echo " Archivos:"
|
|
echo " $rel_dir/settings.gradle.kts"
|
|
echo " $rel_dir/app/build.gradle.kts"
|
|
echo " $rel_dir/app/src/main/kotlin/$pkg_path/MainActivity.kt"
|
|
echo " $rel_dir/app/src/test/kotlin/$pkg_path/ExampleScreenshotTest.kt"
|
|
echo " $rel_dir/app/src/androidTest/kotlin/$pkg_path/MainActivityTest.kt"
|
|
echo " $rel_dir/app.md"
|
|
echo ""
|
|
echo " Pasos siguientes:"
|
|
echo " fn run gradle_unit_test_bash_infra $rel_dir"
|
|
echo " fn run gradle_screenshot_test_bash_infra $rel_dir"
|
|
echo " fn run gradle_assemble_debug_bash_infra $rel_dir"
|
|
echo ""
|
|
echo " Para tests instrumentados (emulador):"
|
|
echo " fn run android_emulator_start_bash_infra Medium_Phone_API_35"
|
|
echo " fn run gradle_instrumented_test_bash_infra $rel_dir"
|
|
echo " fn run android_emulator_stop_bash_infra"
|
|
echo ""
|
|
echo " Package: $pkg_id"
|
|
echo " FnTheme composite: $ui_rel_path"
|
|
}
|
|
|
|
# Permitir invocacion directa via 'fn run init_kotlin_app ...'
|
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
|
init_kotlin_app "$@"
|
|
fi
|