Skip to content

Repository files navigation

🤖 Android AI-Optimized Skeleton

A starting point for modern Android development, built to work seamlessly with AI coding agents (Claude Code, JetBrains AI Assistant, or any agent GUI). It pairs strict architectural guardrails with a real, already-wired tech stack and deterministic scaffolding, so routine feature boilerplate never has to be generated by a model in the first place.

Contents

🧱 Tech Stack

Concern Library
UI Jetpack Compose + Material 3
DI Hilt (KSP)
Networking Retrofit + OkHttp (Gson converter)
Async / state Kotlin Coroutines + StateFlow
Architecture Feature-first Clean Architecture + MVI (UiEvent in, StateFlow<UiState> out)
Modules Gradle multi-module: :app, :core-ui, :core-network, :core-utils, :auth (see Project structure)

None of this is aspirational — Application, KSP annotation processing, and a base NetworkModule (OkHttp + Retrofit singletons) are already wired into Gradle and verified to build. kotlinx-coroutines-test is also wired in (testImplementation) — every feature scripts/new_feature.sh generates ships with a real, passing ViewModel test, not a placeholder.

Request/state flow (MVI, one feature)

┌──────────────────────────────┐
│         Composable UI        │
└──────────────┬───────────────┘
               │ UiEvent
               ▼
┌──────────────────────────────┐
│          ViewModel           │
│  StateFlow<UiState>          │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│           UseCase            │
└──────────────┬───────────────┘
               │
               ▼
┌──────────────────────────────┐
│         Repository           │
└───────┬───────────────┬──────┘
        │               │
        ▼               ▼
Remote Data       Local Data
        │               │
        └───────┬───────┘
                ▼
          Domain Models

See ONBOARDING.md §2 for what UiEvent/UiState/UseCase actually are and a walked-through example.

🌗 Light and Dark Mode

Every app needs an answer to this, and it's a one-line decision, not a dependency. ui/theme/ThemeConfig.kt has a single value:

object ThemeConfig {
    val mode: ThemeMode = ThemeMode.SYSTEM  // or ThemeMode.LIGHT, or ThemeMode.DARK
}
Value Behavior
SYSTEM (default) Follows the device's light/dark setting — what most apps want
LIGHT Always light, ignores the device setting — for an app with only a light brand look
DARK Always dark, ignores the device setting — for a media/photo-first app

This controls light vs. dark mode resolution only — the actual colors for each mode still come from ui/theme/Color.kt's LightColorScheme/DarkColorScheme (or dynamic color on Android 12+, on by default). Changing ThemeConfig.mode doesn't require touching Theme.kt, any Composable, or any generated feature.

🧹 Code Style (ktlint)

Wired via the org.jlleitschuh.gradle.ktlint plugin, so style nits get caught by a command instead of a review comment:

./gradlew ktlintCheck   # fails the build on any violation
./gradlew ktlintFormat  # auto-fixes what it can

.editorconfig turns off exactly three of ktlint's standard rules that would otherwise fight this project's real conventions — package-name (this repo's package has an underscore), function-naming (Composables are deliberately PascalCase), and annotation (Hilt's idiomatic class Foo @Inject constructor(...) puts the annotation inline). Everything else stays on. init_skeleton.sh wires the plugin and runs ktlintFormat once automatically during bootstrap, so a fresh project starts ktlint-clean rather than failing its first check on Android Studio's own generated files. scripts/new_feature.sh's templates are written to already be ktlint-clean too, with the same automatic-format call as a safety net for edge cases the templates can't predict statically — unusually long feature/field names, and a project package that doesn't sort where the templates assume. The pre-commit hook runs ktlintCheck on any staged .kt/.kts change, alongside the test suite.

🎨 Design Consistency

docs/ai/ui_synthesis_rules.md tells Claude to route every color/typography choice through ui/theme/MaterialTheme.colorScheme and MaterialTheme.typography — instead of hardcoding a hex value or font size when building UI from a screenshot or description. That's a rule Claude is instructed to follow, not something a linter enforces on its own, so scripts/check_hardcoded_colors.sh closes the gap for the one failure mode most likely to actually happen: a hardcoded Color(0x...) literal landing in a screen or component instead of ui/theme/Color.kt. It's wired into the pre-commit hook, same as the embedded-copy sync check — any Color(0x...) found outside ui/theme/ blocks the commit.

This deliberately doesn't extend to spacing/dimension values (16.dp, 24.dp, etc.) — those are legitimate in UI code in a way colors mostly aren't, and this project hasn't committed to an actual spacing scale yet (see ONBOARDING.md's Known Limitations section). Add one, and a matching check, if and when a real design system exists to enforce.

✅ Requirements

  • Android Studio (recent) or the gradlew wrapper on the command line
  • JDK 11+
  • Android SDK Platform 37.0 installed — compileSdk is pinned there (see Troubleshooting if you hit an AAR metadata error)

🗂 Project Structure

This is a Gradle multi-module project — five modules, not just packages:

Project
│
├── :app
│    └── features/
│
├── :core-ui
│
├── :core-network
│
├── :core-utils
│
└── :auth
Module Contains
:app Entry points + every generated feature
:core-ui Theme + shared widgets
:core-network Hilt-provided OkHttpClient/Retrofit + NetworkConfig.BASE_URL
:core-utils Empty by design — a home for cross-feature helpers as they come up
:auth Empty by design — a wired Gradle boundary (Compose + Hilt configured) for the auth feature; no login/signup UI exists yet since there's no backend to build it against — see auth/README.md

New features generated by scripts/new_feature.sh stay inside :app — they aren't promoted to their own module. Splitting out a :feature-x module is a deliberate call for a project that's grown enough to need it, not something the tooling does automatically.

settings.gradle.kts                # include(":app", ":core-ui", ":core-utils", ":core-network", ":auth")
build.gradle.kts                   # Root — plugin aliases applied false, shared across all 5 modules
gradle/libs.versions.toml          # Version Catalog — every dependency + plugin version/group/artifact for all 5 modules, declared once, referenced as libs.xxx (not hardcoded per module)
local.properties                   # SDK path — machine-specific, gitignored, not part of the template

app/
├── build.gradle.kts                # applicationId, versionCode/Name, depends on all 4 modules below
├── .gitignore                      # /build
└── src/main/java/com/example/android_ai_skeleton/
    ├── AndroidAISkeletonApp.kt      # @HiltAndroidApp entry point (init_skeleton.sh names this App.kt on a fresh run)
    ├── MainActivity.kt              # @AndroidEntryPoint host
    └── features/
        └── <feature_name>/          # One package per feature — see below
            ├── data/                # Retrofit services, repository impls, Hilt bindings
            ├── domain/               # Models, repository interfaces, use cases (no Android deps)
            └── presentation/         # UiState, ViewModel, Compose Screen/Route

core-ui/
├── build.gradle.kts                 # android-library + Compose, no Hilt
├── .gitignore
└── src/main/
    ├── res/values/strings.xml       # retry string (ErrorView's retry button)
    └── java/com/example/android_ai_skeleton/
        ├── shared_widgets/           # Reusable stateless composables — LoadingIndicator, ErrorView, EmptyState, plus buttons/cards/rows you add
        └── ui/theme/                 # Color.kt, Theme.kt, Type.kt, ThemeConfig.kt — the single source of truth for styling

core-network/
├── build.gradle.kts                 # android-library + Hilt/KSP; exposes Retrofit/OkHttp as `api` so feature code compiles against them directly
├── .gitignore
└── src/main/java/com/example/android_ai_skeleton/core/
    ├── di/                           # Hilt modules (NetworkModule, etc.)
    └── network/                      # NetworkConfig (BASE_URL), shared network types

core-utils/
├── build.gradle.kts                 # android-library, no Compose/Hilt/network — empty by design
└── .gitignore                       # No src/ yet — a home for cross-feature helpers as they come up

auth/
├── build.gradle.kts                 # android-library + Compose + Hilt, depends on core-ui/core-network/core-utils
├── .gitignore
└── README.md                        # Why this module is empty (no backend/session model yet) + the domain/data/presentation shape to follow when you add real auth logic

scripts/new_feature.sh            # Deterministic feature scaffold generator (also emits a real ViewModel test)
scripts/check_embedded_sync.sh    # Verifies init_skeleton.sh's embedded copies match the standalone files
scripts/check_hardcoded_colors.sh # Flags a Color(0x...) literal found outside ui/theme/, across all 5 modules
.claude/skills/new-feature/       # /new-feature — minimal-command scaffolding
.claude/skills/ui-from-image/     # /ui-from-image — screenshot → Compose screen
.claude/skills/translate/         # /translate — generate/update a localized values-<lang>/strings.xml
.claude/skills/status/            # /status — live project snapshot (features built, TODOs, sync state)
.claude/settings.json             # Shared permission allowlist (gradlew, git status/diff/log/show, this repo's own scripts)
.githooks/pre-commit              # Blocks a commit if init_skeleton.sh's embedded copies have drifted, a staged .kt file has a hardcoded color, or a staged .kt/.kts change fails ktlint, the build, or a test
.editorconfig                     # ktlint rule overrides (package-name, function-naming) — see Code Style below
CLAUDE.md                         # Anchor file — tech stack + pointers to docs/ai/*.md, auto-loaded every Claude Code session
docs/ai/                          # Architecture / Compose / UI-synthesis rules the AI reads
docs/PROJECT_MAP.md               # One-line-per-file orientation for everything not covered by the feature convention
TODO.md                           # Auto-appended by new_feature.sh — manual follow-ups per generated feature (created on first use)

🚀 Getting Started

init_skeleton.sh is a single, self-contained file — copy just that one file into any single-module Android Studio project and running it produces everything else: the :core-ui/:core-network/:core-utils/:auth Gradle modules (moving the default template's ui/theme/ into :core-ui in the process), the folder structure, the meta-files, scripts/new_feature.sh + scripts/check_embedded_sync.sh, all four .claude/skills/, a shared .claude/settings.json permission allowlist, a configurable ui/theme/ThemeConfig.kt (see below), the shared_widgets/ composables new_feature.sh's generated screens use, ktlint wired into Gradle with .editorconfig (see Code Style) — including an automatic one-time ktlintFormat pass so the fresh project starts clean — and the full Hilt/KSP/Retrofit/OkHttp/Coroutines/kotlinx-coroutines-test Gradle wiring (actual dependencies and plugins, not just docs about them). If a .git directory exists, it also wires a pre-commit hook (.githooks/pre-commit via core.hooksPath) that blocks a commit if the embedded copies below have drifted, a staged Kotlin file has a hardcoded Color(0x...) literal outside ui/theme/, or a staged Kotlin/Gradle change fails ktlintCheck, doesn't compile, or fails a test. It's safe to re-run any time — every step is skip-if-already-done, so it never clobbers something you've since customized.

Starting a brand-new Android Studio project (not cloning this repo)? This is the entire setup:

# 1. Create your project normally in Android Studio (your own package name).
# 2. Copy this one file into its root, then:
chmod +x init_skeleton.sh
./init_skeleton.sh

That's it — no separate scripts/skills to copy by hand, it self-extracts them on first run.

Cloning this repo instead, as the starting point for your own app? Give it your real package name and it renames everything to match — namespace, applicationId, folder layout, and every package/import declaration:

./init_skeleton.sh com.yourcompany.yourapp

If this project has already been built once before a rename, run ./gradlew clean once afterward — stale incremental/KSP build state from the old package name can otherwise cause a spurious compile error on the next build.

Then build normally:

./gradlew :app:assembleDebug

Maintainer note: scripts/new_feature.sh, scripts/check_embedded_sync.sh, scripts/check_hardcoded_colors.sh, .githooks/pre-commit, all four SKILL.md files, CLAUDE.md, docs/ai/*.md/docs/PROJECT_MAP.md, and .editorconfig are all embedded inside init_skeleton.sh so the single-file copy works. If you edit a standalone copy, update the matching embedded block in init_skeleton.sh too — run ./scripts/check_embedded_sync.sh to check, or just try to commit: the pre-commit hook runs it for you and blocks the commit if anything's out of sync. That check only covers literal cat << 'DELIM' > path blocks, though — the four module build.gradle.kts files, shared_widgets/, NetworkModule.kt, and ThemeConfig.kt are written via interpolated heredocs ($PKG, $THEME_FUN_FOR_WIDGETS) so they can adapt to a renamed package, and those aren't auto-diffed against anything. If you change one of the real files those correspond to, mirror the edit into init_skeleton.sh by hand and verify it — check_embedded_sync.sh will report them as fine either way.

🧠 The AI "Brain" (Meta-Files)

Instead of re-explaining your architecture in every prompt, the AI reads a small set of meta-files to learn the team's standards once:

File Forces the AI to...
CLAUDE.md Know the tech stack and where the deeper rules live (the anchor / table of contents)
docs/ai/architecture.md Use MVI + feature-first Clean Architecture (data / domain / presentation) — one UiEvent in, one StateFlow<UiState> out, no direct ViewModel function calls from Composables
docs/ai/compose_rules.md Avoid legacy XML; use StateFlow, stateless Composables, mandatory @Preview, never hardcode user-facing text (always stringResource()), give every Icon/Image a real or explicit-null contentDescription, and never call a ViewModel function directly from a Composable — dispatch a UiEvent instead
docs/ai/ui_synthesis_rules.md Map screenshots/HTML to MaterialTheme colors & typography — never hardcode HEX values

⚡ Fastest Path: Minimal-Command Generation (Claude Code)

Don't hand-write prompts for routine scaffolding — this repo ships a generator script plus three Skills that use it, so a new feature costs tokens roughly proportional to how custom its UI/logic actually is, not to how much Clean Architecture boilerplate it requires.

1. scripts/new_feature.sh — the generator itself

Pure bash, no LLM involved:

./scripts/new_feature.sh <feature_name> "field:Type,field:Type,..."

# example
./scripts/new_feature.sh user_settings "email:String,notificationsEnabled:Boolean"

Quote the field list — it's comma-separated, and without quotes the shell splits it into multiple arguments before the script ever sees it, silently dropping every field after the first.

This stamps out, under features/<feature_name>/: the domain model, a Repository interface + impl, a UseCase, a Retrofit ApiService + Hilt binding modules, an MVI @HiltViewModel (StateFlow<UiState> out, sealed UiEvent + single onEvent(event) in), a Compose Screen/Route with two @Preview functions and a working error-retry wired through the same event, and a real <Feature>ViewModelTest.kt — success/failure cases against a fake repository, with sensible per-type fake field values (String"test", a nullable type → null, an unrecognized type → a TODO() that compiles but fails loudly at test runtime instead of guessing). It also appends the two remaining manual steps to TODO.md, and — if ktlint is wired in — runs ktlintFormat as a safety net so the output stays ktlint-clean regardless of feature/field name length. All of it wired to the real Hilt/Retrofit stack. It refuses to run if the feature already exists, so it's safe to call directly.

2. /new-feature — the minimal-command wrapper

/new-feature user_settings "email:String,notificationsEnabled:Boolean"

Claude reads only the two small rule files it actually needs (architecture.md + compose_rules.md), runs the script above, fills in the one real placeholder left behind (the API endpoint, if you gave one), builds out real UI only if you asked for something beyond the generic placeholder screen, and tells you what's left to wire up by hand (navigation, BASE_URL).

3. /ui-from-image — screenshot → screen, dialog, or component

Attach a screenshot or mockup and say what it is:

/ui-from-image build this as the dashboard screen
/ui-from-image build this confirmation popup
/ui-from-image build this button

Claude reads compose_rules.md + ui_synthesis_rules.md, maps every color/typography choice to MaterialTheme (flagging anything that doesn't map cleanly instead of guessing a hex value), and figures out placement based on what kind of UI it actually is rather than defaulting to "screen":

You ask for Where it lands
A full screen features/<name>/presentation/<Name>Screen.kt
A dialog/popup/bottom sheet/menu <Name>Dialog.kt (or BottomSheet/Popup) — in shared_widgets/ if reusable, or that feature's presentation/ if tightly coupled to its state
A standalone button/card/badge/input field shared_widgets/<Name>.kt — a plain parameterized composable, no wrapper

@Previews get added for the states shown in the reference either way.

4. /translate — generate or update a localized strings.xml

/translate hindi
/translate add spanish support

Only works if UI copy actually lives in res/values/strings.xml (rule 5 in compose_rules.md — no hardcoded text) rather than being inlined in Composables; otherwise there's nothing to translate. Claude reads the English values/strings.xml, works out the right resource qualifier (values-hi, values-es-rMX, etc.), and either creates the locale file fresh or — if it already exists — translates only the keys missing from it, leaving any existing (possibly hand-corrected) translations untouched. Format specifiers (%1$s) and brand/product names (app_name) are preserved as-is. Treat the output as a solid first draft, not a substitute for native-speaker review before shipping.

5. /status — live project snapshot

/status

Computed fresh every time, never from a cached summary: what features exist under features/, whether init_skeleton.sh's embedded copies are in sync (check_embedded_sync.sh), and a short summary of open TODO.md items. Doesn't run a full build/test unless you ask — a status check should be cheap.

🗣️ Manual Prompting (any AI agent)

Using a tool without Claude Code Skills (JetBrains AI Assistant, another agent GUI, etc.)? Get the same result by prompting directly — anchor the AI to the meta-files first, or it will guess your architecture.

Rule 1 — always reference the meta-files:

"Read CLAUDE.md and the rule files in /docs/ai/ to understand our architecture before beginning this task."

Rule 2 — force workspace execution (agent GUIs sometimes dump code into chat instead of writing files):

"Do not output raw code in this chat. Use your internal tools to write the files directly into my project workspace."

Template A — scaffold a new feature:

"I need to build a new feature for 'User Settings'. Read CLAUDE.md and /docs/ai/architecture.md. Create a new feature folder called user_settings and generate the standard Data, Domain, and Presentation layers for it. Include a basic ViewModel exposing StateFlow and a Compose Screen. Write these files directly into the workspace."

Template B — generate UI from a screenshot:

[Upload Image] "I am attaching a screenshot of the new Dashboard UI. Read /docs/ai/compose_rules.md and /docs/ai/ui_synthesis_rules.md. Build this UI as a new Composable screen inside the features/dashboard/presentation/ folder. Ensure you map the colors and text to our MaterialTheme, and extract repeatable cards into standard Composable functions. Write the code directly to the workspace."

🛠 Troubleshooting

CheckAarMetadataWorkAction fails, saying a dependency "requires... version 37 or later" while the project is "compiled against android-36.1". Install the missing platform and re-sync:

sdkmanager "platforms;android-37.0"

(Use the sdkmanager under your SDK's cmdline-tools/latest/bin/. platforms;android-37 alone won't resolve — the versioned form 37.0/37.1 is what's actually published.)

hiltJavaCompileDebug fails with "Could not find class file for '...App'" after renaming the package. This is stale incremental/KSP build state from before the rename, not a real error — run:

./gradlew clean :app:assembleDebug

About

A feature-first, MVI, multi-module Android template built for Claude Code — deterministic scaffolding, zero-token boilerplate, real wired Hilt/Retrofit/Compose stack.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages