diff --git a/.claude/instructions/android-patterns.md b/.claude/instructions/android-patterns.md new file mode 100644 index 0000000..9f4cb72 --- /dev/null +++ b/.claude/instructions/android-patterns.md @@ -0,0 +1,88 @@ +# Android / KMP — Instruções de plataforma + +> Leia este arquivo ao iniciar qualquer task Android/KMP. Ignore `ios/` e `flutter/`. + +--- + +## Abstrações principais + +| Classe | Papel | +|---|---| +| `CraftDBuilder` | Interface base para criar componentes | +| `CraftDBuilderManager` | Registra e resolve builders pelo `key` | +| `CraftDynamic` | Composable principal que renderiza o SDUI | +| `SimpleProperties` | Modelo base de dados (`key` + `value` JSON) | +| `ActionProperties` | Dados de ação (deeplink + analytics) | +| `CraftDComponentKey` | Enum com as chaves de componentes built-in | +| `CraftDViewListener` | Callback de ações para o consumidor | + +--- + +## Estrutura de pastas + +### craftd-core (modelos e abstrações) +``` +commonMain/ + data/ + model/ + base/ → SimpleProperties, SimplePropertiesResponse + action/ → ActionProperties, AnalyticsProperties + [name]/ → [Name]Properties.kt para cada componente + domain/ → enums e sealed classes (CraftDAlign, CraftDTextStyle) + presentation/ → CraftDViewListener, CraftDComponentKey + extensions/ → funções de extensão +``` + +### craftd-compose (implementação Compose/KMP) +``` +commonMain/ + builder/ → CraftDBuilder.kt (interface), CraftDBuilderManager.kt + ui/ + [name]/ + CraftD[Name].kt → o @Composable do componente + CraftD[Name]Builder.kt → implementa CraftDBuilder + extensions/ → funções utilitárias Compose +``` + +### craftd-xml (implementação View System) +``` +src/main/kotlin/.../ + ui/ + [name]/ + CraftD[Name]Component.kt → custom View + CraftD[Name]ComponentRender.kt → implementa CraftDViewRenderer + builder/ + CraftDBuilderManager.kt → getBuilderRenders() +``` + +### Padrão por novo componente (exemplo: CraftDFoo) + +1. `craftd-core/commonMain/data/model/foo/FooProperties.kt` — data class do modelo +2. `craftd-compose/commonMain/ui/foo/CraftDFoo.kt` — composable +3. `craftd-compose/commonMain/ui/foo/CraftDFooBuilder.kt` — builder +4. `craftd-xml/src/main/kotlin/.../ui/foo/CraftDFooComponent.kt` — custom View +5. `craftd-xml/src/main/kotlin/.../ui/foo/CraftDFooComponentRender.kt` — render +6. Registrar no `CraftDBuilderManager` de cada módulo +7. Adicionar ao `app-sample-android` (Compose + XML) e ao `dynamic.json` + +--- + +## Princípios Compose + +- Composables **stateless** — estado vem do caller (state hoisting) +- Todo componente expõe `modifier: Modifier = Modifier` +- Sem valores hardcoded de cor ou tipografia — usar `MaterialTheme.colorScheme` e `MaterialTheme.typography` +- Todo componente interativo: touch target mínimo de 48x48dp + +## Build + +- Dependências sempre via `libs.versions.toml` — nunca versão hardcoded no `build.gradle.kts` +- Configuração compartilhada entre módulos vai em convention plugin no `build-logic/` +- Rodar `./gradlew build` em `android_kmp/` após cada task antes de marcar `[x]` + +## Testes + +- JUnit4 + MockK para testes Android +- `kotlin("test")` + `kotlinx.serialization` + `compose.runtime` para commonTest +- Nomenclatura em backtick: `` `given X when Y then Z` `` +- Path espelha o source: `src/commonTest/kotlin/...` diff --git a/.claude/instructions/flutter-patterns.md b/.claude/instructions/flutter-patterns.md new file mode 100644 index 0000000..ff654b4 --- /dev/null +++ b/.claude/instructions/flutter-patterns.md @@ -0,0 +1,50 @@ +# Flutter — Instruções de plataforma + +> Leia este arquivo ao iniciar qualquer task Flutter. Ignore `android_kmp/` e `ios/`. + +--- + +## Abstrações principais + +| Classe | Papel | +|---|---| +| `CraftDynamic` | Widget principal que renderiza o SDUI | +| `CraftDViewListener` | Callback de ações para o consumidor | +| `SimpleProperties` | Modelo base de dados | +| `ActionProperties` | Dados de ação (deeplink + analytics) | +| `CraftDAlign` | Alinhamento de componentes | + +--- + +## Estrutura de pastas + +``` +flutter/craftd_widget/ + lib/ + src/ + builder/ → CraftDBuilder (abstract), CraftDBuilderManager + ui/ + [name]/ + craftd_[name].dart → Widget do componente + craftd_[name]_builder.dart → implementa CraftDBuilder + model/ + [name]_properties.dart → classe do modelo +``` + +## Padrão por novo componente (exemplo: CraftDFoo) + +1. `lib/src/model/foo_properties.dart` — classe do modelo +2. `lib/src/ui/foo/craftd_foo.dart` — Widget +3. `lib/src/ui/foo/craftd_foo_builder.dart` — implementa CraftDBuilder +4. Registrar no `CraftDBuilderManager` +5. Adicionar ao sample app Flutter + +## Convenções + +- Nomes de arquivos em `snake_case` +- Classes em `PascalCase` com prefixo `CraftD` +- Dependências externas (ex: cached_network_image) injetadas via construtor, nunca acopladas no builder + +## Referência + +Consultar `CraftDButton` / `CraftDButtonBuilder` como padrão antes de criar algo novo. diff --git a/.claude/instructions/ios-patterns.md b/.claude/instructions/ios-patterns.md new file mode 100644 index 0000000..edbc3f5 --- /dev/null +++ b/.claude/instructions/ios-patterns.md @@ -0,0 +1,44 @@ +# iOS / SwiftUI — Instruções de plataforma + +> Leia este arquivo ao iniciar qualquer task iOS. Ignore `android_kmp/` e `flutter/`. + +--- + +## Abstrações principais + +| Classe | Papel | +|---|---| +| `CraftDBuilder` | Protocol base para criar componentes | +| `CraftDBuilderManager` | Registra e resolve builders pelo `key` | +| `CraftDynamic` | View principal que renderiza o SDUI | +| `SimpleProperties` | Modelo base de dados | +| `ActionProperties` | Dados de ação (deeplink + analytics) | +| `CraftDViewListener` | Callback de ações para o consumidor | + +--- + +## Estrutura de pastas + +``` +ios/craftd-swiftui/ + Sources/CraftD/ + builder/ → CraftDBuilder.swift (protocol), CraftDBuilderManager.swift + ui/ + [name]/ + CraftD[Name].swift → SwiftUI View do componente + CraftD[Name]Builder.swift → implementa CraftDBuilder + model/ + [Name]Properties.swift → struct do modelo +``` + +## Padrão por novo componente (exemplo: CraftDFoo) + +1. `Sources/CraftD/model/FooProperties.swift` — struct do modelo +2. `Sources/CraftD/ui/foo/CraftDFoo.swift` — SwiftUI View +3. `Sources/CraftD/ui/foo/CraftDFooBuilder.swift` — implementa CraftDBuilder +4. Registrar no `CraftDBuilderManager` +5. Adicionar ao sample app iOS + +## Referência + +Consultar `CraftDButton` / `CraftDButtonBuilder` como padrão antes de criar algo novo. \ No newline at end of file diff --git a/.claude/skills/android-accessibility/SKILL.md b/.claude/skills/android-accessibility/SKILL.md deleted file mode 100644 index 2ab4c8b..0000000 --- a/.claude/skills/android-accessibility/SKILL.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: android-accessibility -description: Checklist de acessibilidade para componentes CraftD. Use ao criar ou revisar componentes Compose. ---- - -# Android Accessibility Checklist - -## Para cada componente CraftD - -- [ ] **Touch target mínimo de 48x48dp** para elementos interativos -- [ ] **`contentDescription`** em imagens e ícones (ou `null` se decorativo) -- [ ] **Contraste WCAG AA**: 4.5:1 para texto normal, 3.0:1 para texto grande/ícones -- [ ] **Semântica correta** em componentes customizados - -## Padrões Compose - -```kotlin -// Touch target -Box(Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp)) { ... } - -// Conteúdo decorativo -Icon(contentDescription = null) - -// Merge semantics em componentes compostos -Modifier.semantics(mergeDescendants = true) { } - -// Heading para navegação por screen reader -Modifier.semantics { heading() } -``` - -## CraftD-específico - -- `CraftDButton` e componentes clicáveis devem garantir 48dp de touch target -- `onAction` deve ter `contentDescription` significativa quando possível diff --git a/.claude/skills/android-testing/SKILL.md b/.claude/skills/android-testing/SKILL.md index c9b38d2..2c83d67 100644 --- a/.claude/skills/android-testing/SKILL.md +++ b/.claude/skills/android-testing/SKILL.md @@ -10,7 +10,7 @@ description: Testing strategies for Android/KMP. Use when creating or reviewing | Nível | Foco | Ferramentas | |---|---|---| | Unit | Lógica isolada (ViewModels, Repositories, Builders) | JUnit4, MockK | -| Integration | Interação entre componentes | AndroidX Test, Hilt | +| Integration | Interação entre componentes | AndroidX Test | | Screenshot | Verificação visual de UI | Roborazzi (roda na JVM, sem emulador) | ## Padrão no CraftD @@ -62,12 +62,3 @@ androidUnitTest.dependencies { ./gradlew recordRoborazziDebug # grava baseline ./gradlew verifyRoborazziDebug # verifica regressão ``` - -## Hilt em testes - -```kotlin -@HiltAndroidTest -class MyTest { - @get:Rule val hiltRule = HiltAndroidRule(this) -} -``` diff --git a/.claude/skills/compose-performance-audit/SKILL.md b/.claude/skills/compose-performance-audit/SKILL.md deleted file mode 100644 index 0c664af..0000000 --- a/.claude/skills/compose-performance-audit/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: compose-performance-audit -description: Audit and improve Jetpack Compose runtime performance. Use when diagnosing slow rendering, janky scrolling, or excessive recompositions in CraftD components. ---- - -# Compose Performance Audit - -## Workflow - -1. **Code-first review** — analyze the provided Composable for: - - Recomposition storms from unstable parameters - - Unstable keys in lazy lists - - Heavy work during composition (formatting, sorting, allocation) - - Missing `remember` calls - - Deep nesting causing layout thrash - -2. **Profiling guidance** (if code review is inconclusive): - - Layout Inspector + Recomposition Highlights - - Perfetto traces - - Macrobenchmark - - Always profile on release builds with R8 enabled - -3. **Remediate**: - - Add stability annotations (`@Stable`, `@Immutable`) - - Use stable keys in lazy lists - - Defer state reads - - Memoize with `remember` / `derivedStateOf` - -## CraftD-specific concerns - -- `CraftDynamic` renderiza listas de componentes — prestar atenção em recomposições desnecessárias quando `properties` muda -- Builders devem ser `remember`-ed no lado do consumidor -- `ActionProperties` como parâmetro de lambda deve ser estável diff --git a/.claude/skills/compose-ui/SKILL.md b/.claude/skills/compose-ui/SKILL.md index 3f243a3..abdccaf 100644 --- a/.claude/skills/compose-ui/SKILL.md +++ b/.claude/skills/compose-ui/SKILL.md @@ -3,44 +3,113 @@ name: compose-ui description: Boas práticas para Composables no CraftD. Use ao escrever ou revisar componentes Compose. --- -# Jetpack Compose Best Practices +# Criando um componente Compose no CraftD -## 1. State Hoisting +## Checklist obrigatório -Composables do CraftD devem ser stateless — estado vem do caller: +- [ ] `XxxProperties` definido em `craftd-core/commonMain` +- [ ] `CraftDComponentKey.XXX_COMPONENT` adicionado ao enum em `craftd-core` +- [ ] Composable `CraftDXxx` criado em `craftd-compose/commonMain` +- [ ] `CraftDXxxBuilder` criado e implementa `CraftDBuilder` +- [ ] Builder registrado no `CraftDBuilderManager` +- [ ] `onAction`/fallback coberto (mesmo que seja no-op) +- [ ] Teste unitário em `commonTest` +- [ ] Documentação em `docs/how-to-use/compose.md` + +--- + +## 1. Composable — assinatura padrão ```kotlin @Composable fun CraftDXxx( properties: XxxProperties, - onAction: (ActionProperties) -> Unit, - modifier: Modifier = Modifier // sempre como último parâmetro opcional + onAction: () -> Unit = {}, + modifier: Modifier = Modifier, +) { ... } +``` + +- `modifier` sempre como último parâmetro opcional, aplicado no elemento raiz +- `onAction` com default `{}` — nunca omitir o parâmetro +- Composable é **stateless**: estado vem do caller, nunca interno + +### `onAction` condicional (quando actionProperties pode ser null) + +```kotlin +val clickableModifier = if (properties.actionProperties != null) { + modifier.clickable { onAction() } +} else modifier +``` + +--- + +## 2. Builder — estrutura padrão + +```kotlin +class CraftDXxxBuilder( + override val key: String = CraftDComponentKey.XXX_COMPONENT.key +) : CraftDBuilder { + @Composable + override fun craft(model: SimpleProperties, listener: CraftDViewListener) { + val properties = model.value.convertToElement() + properties?.let { + CraftDXxx( + properties = it, + onAction = { it.actionProperties?.let { action -> listener.invoke(action) } }, + ) + } + } +} +``` + +### Builder com dependência externa (ex: imageLoader) + +Quando o componente precisa de lib de terceiro (Coil, etc.), recebe via construtor — nunca acopla diretamente: + +```kotlin +class CraftDXxxBuilder( + private val imageLoader: @Composable (url: String, contentDescription: String?, modifier: Modifier) -> Unit, + override val key: String = CraftDComponentKey.XXX_COMPONENT.key, +) : CraftDBuilder { ... } +``` + +--- + +## 3. Registro no CraftDBuilderManager + +```kotlin +// Registro interno (sem dependência externa) +CraftDComponentKey.XXX_COMPONENT.key to CraftDXxxBuilder() + +// Builder com dependência externa — consumidor injeta via add() +craftDBuilderManager.add( + CraftDXxxBuilder(imageLoader = { url, desc, mod -> /* Coil, etc. */ }) ) ``` -## 2. Modifier +--- + +## 4. Theming -- Sempre expor `modifier: Modifier = Modifier` como parâmetro -- Aplicar no elemento raiz do componente -- Ordem importa: `padding().clickable()` ≠ `clickable().padding()` +- Usar `MaterialTheme.colorScheme` e `MaterialTheme.typography` +- Nunca valores hardcoded de cor, tamanho de fonte ou espaçamento + +--- -## 3. Performance +## 5. Performance - `remember` para computações caras entre recomposições - `derivedStateOf` quando estado muda frequentemente mas UI atualiza em threshold - Lambdas estáveis para evitar recomposição desnecessária de filhos -## 4. Theming - -- Usar `MaterialTheme.colorScheme` e `MaterialTheme.typography` -- Nunca valores hardcoded de cor ou tipografia +--- -## 5. Previews +## 6. Preview ```kotlin @Preview(showBackground = true) @Composable -private fun CraftDButtonPreview() { - CraftDButton(properties = ButtonProperties(...)) {} +private fun CraftDXxxPreview() { + CraftDXxx(properties = XxxProperties(...)) {} } ``` diff --git a/CLAUDE.md b/CLAUDE.md index db3b982..fb79905 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,106 +56,113 @@ docs/ # documentação do site (MkDocs) --- -## Abstrações principais por plataforma +## Contexto por plataforma -As três plataformas espelham os mesmos conceitos com nomes equivalentes. +Antes de iniciar qualquer task, identifique a plataforma e leia o arquivo correspondente em `.claude/instructions/`: -### Android / KMP (Kotlin) +- Android/KMP → `.claude/instructions/android-patterns.md` +- iOS → `.claude/instructions/ios-patterns.md` +- Flutter → `.claude/instructions/flutter-patterns.md` -| Classe | Papel | -|---|---| -| `CraftDBuilder` | Interface base para criar componentes | -| `CraftDBuilderManager` | Registra e resolve builders pelo `key` | -| `CraftDynamic` | Composable principal que renderiza o SDUI | -| `SimpleProperties` | Modelo base de dados (`key` + `value` JSON) | -| `ActionProperties` | Dados de ação (deeplink + analytics) | -| `CraftDComponentKey` | Enum com as chaves de componentes built-in | -| `CraftDViewListener` | Callback de ações para o consumidor | +Ao gerar um `proposal.md` via `/propose`, detecte a plataforma na descrição do usuário e adicione frontmatter no início do arquivo: -### iOS / SwiftUI (Swift — `ios/craftd-swiftui/`) +``` +--- +platform: android # mencionou android / compose / xml / kmp +platform: ios # mencionou ios / swiftui / swift +platform: flutter # mencionou flutter / dart +platform: all # multiplatforma ou não ficou claro +--- +``` -| Classe | Papel | -|---|---| -| `CraftDBuilder` | Protocol base para criar componentes | -| `CraftDBuilderManager` | Registra e resolve builders pelo `key` | -| `CraftDynamic` | View principal que renderiza o SDUI | -| `SimpleProperties` | Modelo base de dados | -| `ActionProperties` | Dados de ação (deeplink + analytics) | -| `CraftDViewListener` | Callback de ações para o consumidor | +Ao iniciar `/apply`, leia o campo `platform:` do `proposal.md` da change e carregue o arquivo de instructions correspondente antes de qualquer outra leitura. -### Flutter (Dart — `flutter/craftd_widget/`) +--- -| Classe | Papel | -|---|---| -| `CraftDynamic` | Widget principal que renderiza o SDUI | -| `CraftDViewListener` | Callback de ações para o consumidor | -| `SimpleProperties` | Modelo base de dados | -| `ActionProperties` | Dados de ação (deeplink + analytics) | -| `CraftDAlign` | Alinhamento de componentes | +## Convenções de código -> Ao adicionar um novo componente, ele deve ser implementado nas três plataformas seguindo a mesma abstração de cada uma. Consultar `CraftDButton` / `CraftDButtonBuilder` como referência em todas. +- **Kotlin:** segue as convenções oficiais do Kotlin. Prefere `data class` para modelos. +- **Nomenclatura de componentes:** prefixo `CraftD` em tudo que é parte da lib (ex: `CraftDButton`, `CraftDButtonBuilder`). +- **Testes:** JUnit4 + MockK. Nomenclatura em backtick: `` `given X when Y then Z` ``. Path espelha o source: `src/test/java/...` +- **Commits:** mensagens em inglês, semânticas (`feat:`, `fix:`, `test:`, `chore:`, `docs:`). --- -## Padrão de estrutura de pastas +## Implementação de tasks -### craftd-core (modelos e abstrações) -``` -commonMain/ - data/ - model/ - base/ → SimpleProperties, SimplePropertiesResponse - action/ → ActionProperties, AnalyticsProperties - [name]/ → [Name]Properties.kt para cada componente - domain/ → enums e sealed classes (CraftDAlign, CraftDTextStyle) - presentation/ → CraftDViewListener, CraftDComponentKey - extensions/ → funções de extensão -``` +Ao concluir cada task de um `tasks.md`: +1. Implemente o código da task +2. Rode `./gradlew build` no módulo afetado (`android_kmp/`) +3. Corrija erros de compilação se houver +4. Só então marque `[x]` no `tasks.md` -### craftd-compose (implementação Compose/KMP) -``` -commonMain/ - builder/ → CraftDBuilder.kt (interface), CraftDBuilderManager.kt - ui/ - [name]/ - CraftD[Name].kt → o @Composable do componente - CraftD[Name]Builder.kt → implementa CraftDBuilder - extensions/ → funções utilitárias Compose -``` +Nunca marcar `[x]` antes do build passar. -### Padrão por novo componente (exemplo: CraftDImage) +### Orquestração com agents para componentes Android/KMP -1. `craftd-core/commonMain/data/model/image/ImageProperties.kt` — data class do modelo -2. `craftd-compose/commonMain/ui/image/CraftDImage.kt` — composable -3. `craftd-compose/commonMain/ui/image/CraftDImageBuilder.kt` — builder -4. Equivalentes em `ios/craftd-swiftui/` e `flutter/craftd_widget/` com a mesma estrutura +Ao aplicar uma change que adiciona um novo componente Android/KMP (detectável pela estrutura das tasks: core → compose/xml → docs/sample), usar agents paralelos com worktrees isoladas seguindo estas rodadas: ---- +**Rodada 1** — sequencial (core é dependência das demais): +- Agent Core → tasks de `craftd-core` (model, enum, key) -## Princípios de desenvolvimento +**Rodada 2** — paralelo (após Rodada 1 mergeada): +- Agent Compose → tasks de `craftd-compose` (composable, builder, registro, testes) +- Agent XML → tasks de `craftd-xml` (render, registro) -### Compose -- Composables devem ser **stateless** — estado vem sempre do caller (state hoisting) -- Todo componente expõe `modifier: Modifier = Modifier` como parâmetro -- Sem valores hardcoded de cor ou tipografia — usar `MaterialTheme.colorScheme` e `MaterialTheme.typography` -- Todo componente interativo deve ter **touch target mínimo de 48x48dp** +**Rodada 3** — sequencial (após Rodada 2): +- Agent Docs/Sample → tasks de documentação e sample app -### Arquitetura -- A camada `domain` não pode ter dependências Android — apenas Kotlin puro -- Repositórios usam `suspend functions` main-safe +**Rodada 4** — revisor (após Rodada 3): +- Agent Revisor → revisa todo o código produzido seguindo as regras de review do CLAUDE.md. Não faz build — cada agent já validou o seu. -### Build -- Dependências sempre via `libs.versions.toml` — nunca versão hardcoded no `build.gradle.kts` -- Configuração compartilhada entre módulos vai em convention plugin no `build-logic/` +Cada agent roda em worktree isolada (`isolation: "worktree"`) e valida o build antes de marcar `[x]`. ---- +### Custo de contexto — diretrizes para agents -## Convenções de código +**Escopo de plataforma — ignorar pastas irrelevantes:** +- Tasks Android/KMP → ignorar `ios/` e `flutter/` +- Tasks iOS → ignorar `android_kmp/` e `flutter/` +- Tasks Flutter → ignorar `android_kmp/` e `ios/` -- **Kotlin:** segue as convenções oficiais do Kotlin. Prefere `data class` para modelos. -- **Nomenclatura de componentes:** prefixo `CraftD` em tudo que é parte da lib (ex: `CraftDButton`, `CraftDButtonBuilder`). -- **Testes:** JUnit4 + MockK. Nomenclatura em backtick: `` `given X when Y then Z` ``. Path espelha o source: `src/test/java/...` -- **Commits:** mensagens em inglês, semânticas (`feat:`, `fix:`, `test:`, `chore:`, `docs:`). +Nunca ler, listar ou referenciar arquivos fora da plataforma da task em execução. + +**Quando NÃO usar agent (fazer inline):** +- Rodada 3 (Docs/Sample) e Rodada 4 (Revisor) — edições simples, o overhead do agent supera o benefício +- Qualquer task com menos de 10 arquivos a editar e sem necessidade de build isolado + +**Quando usar agent com worktree:** +- Rodadas 1 e 2 — compilação isolada necessária, risco de conflito entre módulos paralelos + +**Como montar o prompt de um agent:** +- Passar os caminhos exatos dos arquivos relevantes +- Incluir o trecho de código de referência (ex: o builder existente que deve ser replicado) +- Nunca escrever "leia o projeto e implemente" — especificar o quê e onde + +**Modelo por tipo de tarefa:** +- Edições mecânicas (JSON, doc, registro simples): usar `model: "haiku"` +- Lógica, compilação e decisões arquiteturais: Sonnet (default) + +Após cada mudança de estado relevante (agent iniciado, concluído ou com erro), exibir tabela de progresso: + +| Agent | Status | Tasks | +|---|---|---| +| Agent Core | ✓ Completo | 1.x | +| Agent Compose | ⟳ Rodando | 2.x | +| Agent XML | ⏳ Aguardando | 3.x | + +Ícones: `⟳` rodando, `✓` completo, `⏳` aguardando, `✗` erro. + +Durante a execução, reportar progresso no formato: + +``` +[Agent Core] ✓ 1.1 IMAGE_COMPONENT adicionado +[Agent Core] ✓ 1.2 CraftDContentScale criado +[Agent Compose] ⟳ 2.2 Criando CraftDImage composable... +[Agent XML] ⟳ 3.1 Criando CraftDImageComponent... +[Agent Compose] ✓ 2.2 CraftDImage composable criado +``` + +Usar `⟳` para em progresso e `✓` para concluído. Reportar a cada task iniciada e concluída. --- diff --git a/android_kmp/.claude/settings.local.json b/android_kmp/.claude/settings.local.json new file mode 100644 index 0000000..71714ea --- /dev/null +++ b/android_kmp/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew :craftd-xml:assembleDebug)", + "Bash(./gradlew :craftd-core:build)", + "Bash(git fetch:*)", + "Bash(gh pr:*)", + "Bash(git merge:*)", + "Bash(./gradlew :craftd-core:testDebugUnitTest)", + "Bash(git add:*)" + ] + } +} diff --git a/android_kmp/app-sample-android/build.gradle.kts b/android_kmp/app-sample-android/build.gradle.kts index db9d4b4..73502c2 100644 --- a/android_kmp/app-sample-android/build.gradle.kts +++ b/android_kmp/app-sample-android/build.gradle.kts @@ -16,7 +16,7 @@ dependencies { implementation(libs.kotlinx.serialization.json) implementation(projects.craftdCore) -// implementation(projects.craftdXml) + implementation(projects.craftdXml) implementation(projects.craftdCompose) implementation(libs.androidx.core) @@ -24,8 +24,6 @@ dependencies { implementation(libs.google.material) implementation(libs.kotlinx.collections.immutable) - implementation("io.github.codandotv:craftd-xml:1.1.0") // revisar se é necessário junto com `projects.craftdXml` - implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version") implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version") implementation("androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version") @@ -37,6 +35,7 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.9.1") implementation("com.squareup.okhttp3:logging-interceptor:4.9.1") implementation("com.squareup.picasso:picasso:2.8") + implementation(libs.coil.compose) implementation("io.insert-koin:koin-androidx-scope:$koin_version") implementation("io.insert-koin:koin-androidx-viewmodel:$koin_version") diff --git a/android_kmp/app-sample-android/src/main/assets/dynamic.json b/android_kmp/app-sample-android/src/main/assets/dynamic.json index fabd059..abe9d8f 100644 --- a/android_kmp/app-sample-android/src/main/assets/dynamic.json +++ b/android_kmp/app-sample-android/src/main/assets/dynamic.json @@ -374,5 +374,21 @@ } } } + }, + { + "key": "CraftDImage", + "value": { + "url": "https://picsum.photos/400/200", + "contentScale": "CROP", + "contentDescription": "Sample image from CraftDImage", + "actionProperties": { + "deeplink": "craftd://image/1", + "analytics": { + "category": "image", + "action": "tap", + "label": "sample_banner" + } + } + } } ] \ No newline at end of file diff --git a/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/compose/InitialScreen.kt b/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/compose/InitialScreen.kt index 9bd83d5..a60132d 100644 --- a/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/compose/InitialScreen.kt +++ b/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/compose/InitialScreen.kt @@ -5,9 +5,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage import com.github.codandotv.craftd.app_sample.presentation.compose.customview.MySampleButtonComposeBuilder import com.github.codandotv.craftd.compose.builder.CraftDBuilderManager import com.github.codandotv.craftd.compose.ui.CraftDynamic +import com.github.codandotv.craftd.compose.ui.image.CraftDImageBuilder @Composable fun InitialScreen( @@ -17,6 +19,16 @@ fun InitialScreen( val craftdBuilderManager = remember { CraftDBuilderManager().add( MySampleButtonComposeBuilder(), + CraftDImageBuilder( + imageLoader = { url, contentDescription, modifier, contentScale -> + AsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + ), ) } LaunchedEffect(Unit) { diff --git a/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/xml/SampleCraftDViewModel.kt b/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/xml/SampleCraftDViewModel.kt index 170c7d9..78a8c5e 100644 --- a/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/xml/SampleCraftDViewModel.kt +++ b/android_kmp/app-sample-android/src/main/java/com/github/codandotv/craftd/app_sample/presentation/xml/SampleCraftDViewModel.kt @@ -10,6 +10,7 @@ import com.github.codandotv.craftd.androidcore.presentation.CraftDViewListener import com.github.codandotv.craftd.app_sample.data.SampleCraftDRepository import com.github.codandotv.craftd.xml.builder.CraftDBuilderManager import com.github.codandotv.craftd.xml.ui.CraftDView +import com.squareup.picasso.Picasso import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch @@ -39,9 +40,12 @@ class SampleCraftDViewModel( craft.registerRenderers( CraftDBuilderManager.getBuilderRenders( simpleProperties = list, - ) { action -> - listener.invoke(action) - }) + onAction = { action -> listener.invoke(action) }, + imageLoader = { url, imageView -> + Picasso.get().load(url).into(imageView) + }, + ) + ) } private val listener = object : diff --git a/android_kmp/app-sample-cmp/build.gradle.kts b/android_kmp/app-sample-cmp/build.gradle.kts index e5a5f83..b50d245 100644 --- a/android_kmp/app-sample-cmp/build.gradle.kts +++ b/android_kmp/app-sample-cmp/build.gradle.kts @@ -44,6 +44,7 @@ kotlin { androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) + implementation(libs.coil3.network.okhttp) } commonMain.dependencies { implementation(projects.craftdCompose) @@ -58,6 +59,7 @@ kotlin { implementation(libs.androidx.lifecycle.runtimeCompose) implementation(libs.kotlinx.collections.immutable) implementation(compose.components.resources) + implementation(libs.coil3.compose) } } } \ No newline at end of file diff --git a/android_kmp/app-sample-cmp/src/commonMain/composeResources/files/dynamic.json b/android_kmp/app-sample-cmp/src/commonMain/composeResources/files/dynamic.json index 5997a18..8ab65c8 100644 --- a/android_kmp/app-sample-cmp/src/commonMain/composeResources/files/dynamic.json +++ b/android_kmp/app-sample-cmp/src/commonMain/composeResources/files/dynamic.json @@ -1,4 +1,20 @@ [ + { + "key": "CraftDImage", + "value": { + "url": "https://picsum.photos/400/200", + "contentScale": "CROP", + "contentDescription": "Sample image from CraftDImage", + "actionProperties": { + "deeplink": "craftd://image/1", + "analytics": { + "category": "image", + "action": "tap", + "label": "sample_banner" + } + } + } + }, { "key": "CraftDTextView", "value": { diff --git a/android_kmp/app-sample-cmp/src/commonMain/kotlin/org/example/project/presentation/compose/InitialScreen.kt b/android_kmp/app-sample-cmp/src/commonMain/kotlin/org/example/project/presentation/compose/InitialScreen.kt index f38c498..74fe57d 100644 --- a/android_kmp/app-sample-cmp/src/commonMain/kotlin/org/example/project/presentation/compose/InitialScreen.kt +++ b/android_kmp/app-sample-cmp/src/commonMain/kotlin/org/example/project/presentation/compose/InitialScreen.kt @@ -6,9 +6,11 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage import org.example.project.presentation.compose.customview.MySampleButtonComposeBuilder import com.github.codandotv.craftd.compose.builder.CraftDBuilderManager import com.github.codandotv.craftd.compose.ui.CraftDynamic +import com.github.codandotv.craftd.compose.ui.image.CraftDImageBuilder import org.example.project.data.SampleCraftDRepositoryImpl @Composable @@ -19,6 +21,16 @@ fun InitialScreen( val craftdBuilderManager = remember { CraftDBuilderManager().add( MySampleButtonComposeBuilder(), + CraftDImageBuilder( + imageLoader = { url, contentDescription, modifier, contentScale -> + AsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } + ), ) } LaunchedEffect(Unit) { diff --git a/android_kmp/build-logic/src/main/java/com.codandotv.android-library.gradle.kts b/android_kmp/build-logic/src/main/java/com.codandotv.android-library.gradle.kts index 5a883e3..96b0027 100644 --- a/android_kmp/build-logic/src/main/java/com.codandotv.android-library.gradle.kts +++ b/android_kmp/build-logic/src/main/java/com.codandotv.android-library.gradle.kts @@ -17,6 +17,10 @@ android { setupCompileOptions() + kotlinOptions { + jvmTarget = Config.jvmTargetValue.target + } + setupPackingOptions() setupAndroidDefaultConfig() diff --git a/android_kmp/craftd-compose/build.gradle.kts b/android_kmp/craftd-compose/build.gradle.kts index c71f863..fac8c3e 100644 --- a/android_kmp/craftd-compose/build.gradle.kts +++ b/android_kmp/craftd-compose/build.gradle.kts @@ -23,5 +23,9 @@ kotlin { implementation(compose.material3) implementation(libs.kotlinx.collections.immutable) } + + commonTest.dependencies { + implementation(kotlin("test")) + } } } \ No newline at end of file diff --git a/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/extensions/UtilsCompose.kt b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/extensions/UtilsCompose.kt index 3930101..24da007 100644 --- a/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/extensions/UtilsCompose.kt +++ b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/extensions/UtilsCompose.kt @@ -3,11 +3,13 @@ package com.github.codandotv.craftd.compose.extensions import androidx.compose.foundation.layout.Arrangement import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import com.github.codandotv.craftd.androidcore.domain.CraftDAlign +import com.github.codandotv.craftd.androidcore.domain.CraftDContentScale import com.github.codandotv.craftd.androidcore.domain.CraftDTextStyle fun CraftDTextStyle?.toTextStyle() = when (this) { @@ -35,6 +37,17 @@ fun CraftDAlign?.toAlignmentCompose() : Alignment.Vertical = when (this) { else -> Alignment.Top } +internal fun CraftDContentScale?.toContentScale(): ContentScale = when (this) { + CraftDContentScale.CROP -> ContentScale.Crop + CraftDContentScale.FIT -> ContentScale.Fit + CraftDContentScale.FILL_BOUNDS -> ContentScale.FillBounds + CraftDContentScale.FILL_WIDTH -> ContentScale.FillWidth + CraftDContentScale.FILL_HEIGHT -> ContentScale.FillHeight + CraftDContentScale.INSIDE -> ContentScale.Inside + CraftDContentScale.NONE -> ContentScale.None + null -> ContentScale.Fit +} + fun String?.parseColorCompose(): Color { return runCatching { val clean = this!!.removePrefix("#") diff --git a/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImage.kt b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImage.kt new file mode 100644 index 0000000..0dada21 --- /dev/null +++ b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImage.kt @@ -0,0 +1,27 @@ +package com.github.codandotv.craftd.compose.ui.image + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import com.github.codandotv.craftd.androidcore.data.model.image.ImageProperties +import com.github.codandotv.craftd.compose.extensions.toContentScale + +@Composable +fun CraftDImage( + properties: ImageProperties, + imageLoader: @Composable (url: String, contentDescription: String?, modifier: Modifier, contentScale: ContentScale) -> Unit, + onAction: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val clickableModifier = if (properties.actionProperties != null) { + modifier.clickable { onAction() } + } else modifier + + imageLoader( + properties.url.orEmpty(), + properties.contentDescription, + clickableModifier, + properties.contentScale.toContentScale(), + ) +} diff --git a/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImageBuilder.kt b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImageBuilder.kt new file mode 100644 index 0000000..447533e --- /dev/null +++ b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImageBuilder.kt @@ -0,0 +1,28 @@ +package com.github.codandotv.craftd.compose.ui.image + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import com.github.codandotv.craftd.androidcore.data.convertToElement +import com.github.codandotv.craftd.androidcore.data.model.base.SimpleProperties +import com.github.codandotv.craftd.androidcore.data.model.image.ImageProperties +import com.github.codandotv.craftd.androidcore.presentation.CraftDComponentKey +import com.github.codandotv.craftd.androidcore.presentation.CraftDViewListener +import com.github.codandotv.craftd.compose.builder.CraftDBuilder + +class CraftDImageBuilder( + private val imageLoader: @Composable (url: String, contentDescription: String?, modifier: Modifier, contentScale: ContentScale) -> Unit, + override val key: String = CraftDComponentKey.IMAGE_COMPONENT.key, +) : CraftDBuilder { + @Composable + override fun craft(model: SimpleProperties, listener: CraftDViewListener) { + val imageProperties = model.value.convertToElement() + imageProperties?.let { + CraftDImage( + properties = it, + imageLoader = imageLoader, + onAction = { it.actionProperties?.let { action -> listener.invoke(action) } }, + ) + } + } +} diff --git a/android_kmp/craftd-compose/src/commonTest/kotlin/com/github/codandotv/craftd/compose/extensions/ContentScaleExtensionTest.kt b/android_kmp/craftd-compose/src/commonTest/kotlin/com/github/codandotv/craftd/compose/extensions/ContentScaleExtensionTest.kt new file mode 100644 index 0000000..3bf4e92 --- /dev/null +++ b/android_kmp/craftd-compose/src/commonTest/kotlin/com/github/codandotv/craftd/compose/extensions/ContentScaleExtensionTest.kt @@ -0,0 +1,50 @@ +package com.github.codandotv.craftd.compose.extensions + +import androidx.compose.ui.layout.ContentScale +import com.github.codandotv.craftd.androidcore.domain.CraftDContentScale +import kotlin.test.Test +import kotlin.test.assertEquals + +class ContentScaleExtensionTest { + + @Test + fun `given CraftDContentScale CROP when toContentScale then returns ContentScale Crop`() { + assertEquals(ContentScale.Crop, CraftDContentScale.CROP.toContentScale()) + } + + @Test + fun `given CraftDContentScale FIT when toContentScale then returns ContentScale Fit`() { + assertEquals(ContentScale.Fit, CraftDContentScale.FIT.toContentScale()) + } + + @Test + fun `given CraftDContentScale FILL_BOUNDS when toContentScale then returns ContentScale FillBounds`() { + assertEquals(ContentScale.FillBounds, CraftDContentScale.FILL_BOUNDS.toContentScale()) + } + + @Test + fun `given CraftDContentScale FILL_WIDTH when toContentScale then returns ContentScale FillWidth`() { + assertEquals(ContentScale.FillWidth, CraftDContentScale.FILL_WIDTH.toContentScale()) + } + + @Test + fun `given CraftDContentScale FILL_HEIGHT when toContentScale then returns ContentScale FillHeight`() { + assertEquals(ContentScale.FillHeight, CraftDContentScale.FILL_HEIGHT.toContentScale()) + } + + @Test + fun `given CraftDContentScale INSIDE when toContentScale then returns ContentScale Inside`() { + assertEquals(ContentScale.Inside, CraftDContentScale.INSIDE.toContentScale()) + } + + @Test + fun `given CraftDContentScale NONE when toContentScale then returns ContentScale None`() { + assertEquals(ContentScale.None, CraftDContentScale.NONE.toContentScale()) + } + + @Test + fun `given null CraftDContentScale when toContentScale then returns ContentScale Fit as default`() { + val nullScale: CraftDContentScale? = null + assertEquals(ContentScale.Fit, nullScale.toContentScale()) + } +} diff --git a/android_kmp/craftd-compose/src/commonTest/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImageBuilderTest.kt b/android_kmp/craftd-compose/src/commonTest/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImageBuilderTest.kt new file mode 100644 index 0000000..d64d883 --- /dev/null +++ b/android_kmp/craftd-compose/src/commonTest/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImageBuilderTest.kt @@ -0,0 +1,23 @@ +package com.github.codandotv.craftd.compose.ui.image + +import com.github.codandotv.craftd.androidcore.presentation.CraftDComponentKey +import kotlin.test.Test +import kotlin.test.assertEquals + +class CraftDImageBuilderTest { + + @Test + fun `given CraftDImageBuilder when created then key matches IMAGE_COMPONENT`() { + val builder = CraftDImageBuilder(imageLoader = { _, _, _, _ -> }) + + assertEquals(CraftDComponentKey.IMAGE_COMPONENT.key, builder.key) + } + + @Test + fun `given CraftDImageBuilder when created with custom key then key is overridden`() { + val customKey = "custom_image_key" + val builder = CraftDImageBuilder(imageLoader = { _, _, _, _ -> }, key = customKey) + + assertEquals(customKey, builder.key) + } +} diff --git a/android_kmp/craftd-core/build.gradle.kts b/android_kmp/craftd-core/build.gradle.kts index c4915c9..f25c4da 100644 --- a/android_kmp/craftd-core/build.gradle.kts +++ b/android_kmp/craftd-core/build.gradle.kts @@ -31,5 +31,11 @@ kotlin { implementation(compose.foundation) implementation(compose.material3) } + + commonTest.dependencies { + implementation(kotlin("test")) + implementation(libs.kotlinx.serialization.json) + implementation(compose.runtime) + } } } \ No newline at end of file diff --git a/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/data/model/image/ImageProperties.kt b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/data/model/image/ImageProperties.kt new file mode 100644 index 0000000..9d52763 --- /dev/null +++ b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/data/model/image/ImageProperties.kt @@ -0,0 +1,14 @@ +package com.github.codandotv.craftd.androidcore.data.model.image + +import com.github.codandotv.craftd.androidcore.data.model.action.ActionProperties +import com.github.codandotv.craftd.androidcore.domain.CraftDContentScale +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ImageProperties( + @SerialName("url") val url: String? = null, + @SerialName("contentScale") val contentScale: CraftDContentScale? = null, + @SerialName("contentDescription") val contentDescription: String? = null, + @SerialName("actionProperties") val actionProperties: ActionProperties? = null, +) diff --git a/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/domain/CraftDContentScale.kt b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/domain/CraftDContentScale.kt new file mode 100644 index 0000000..47e546a --- /dev/null +++ b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/domain/CraftDContentScale.kt @@ -0,0 +1,11 @@ +package com.github.codandotv.craftd.androidcore.domain + +enum class CraftDContentScale { + CROP, + FIT, + FILL_BOUNDS, + FILL_WIDTH, + FILL_HEIGHT, + INSIDE, + NONE +} diff --git a/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKey.kt b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKey.kt index 92e7529..3877691 100644 --- a/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKey.kt +++ b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKey.kt @@ -5,6 +5,7 @@ enum class CraftDComponentKey(val key: String) { TEXT_VIEW_COMPONENT("${CRAFT_D}TextView"), BUTTON_COMPONENT("${CRAFT_D}Button"), CHECK_BOX_COMPONENT("${CRAFT_D}CheckBox"), + IMAGE_COMPONENT("${CRAFT_D}Image"), } internal const val CRAFT_D = "CraftD" \ No newline at end of file diff --git a/android_kmp/craftd-core/src/commonTest/kotlin/com/github/codandotv/craftd/androidcore/data/model/image/ImagePropertiesTest.kt b/android_kmp/craftd-core/src/commonTest/kotlin/com/github/codandotv/craftd/androidcore/data/model/image/ImagePropertiesTest.kt new file mode 100644 index 0000000..e28b378 --- /dev/null +++ b/android_kmp/craftd-core/src/commonTest/kotlin/com/github/codandotv/craftd/androidcore/data/model/image/ImagePropertiesTest.kt @@ -0,0 +1,59 @@ +package com.github.codandotv.craftd.androidcore.data.model.image + +import com.github.codandotv.craftd.androidcore.data.model.action.ActionProperties +import com.github.codandotv.craftd.androidcore.domain.CraftDContentScale +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ImagePropertiesTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `given full ImageProperties when serialized and deserialized then all fields match`() { + val original = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "A sample image", + ) + + val serialized = json.encodeToString(ImageProperties.serializer(), original) + val deserialized = json.decodeFromString(ImageProperties.serializer(), serialized) + + assertEquals(original.url, deserialized.url) + assertEquals(original.contentScale, deserialized.contentScale) + assertEquals(original.contentDescription, deserialized.contentDescription) + assertNull(deserialized.actionProperties) + } + + @Test + fun `given ImageProperties with defaults when deserialized from minimal json then nullable fields are null`() { + val minimalJson = """{}""" + + val deserialized = json.decodeFromString(ImageProperties.serializer(), minimalJson) + + assertNull(deserialized.url) + assertNull(deserialized.contentScale) + assertNull(deserialized.contentDescription) + assertNull(deserialized.actionProperties) + } + + @Test + fun `given json with all fields when deserialized then ImageProperties matches expected`() { + val jsonString = """ + { + "url": "https://example.com/photo.jpg", + "contentScale": "FIT", + "contentDescription": "Photo" + } + """.trimIndent() + + val result = json.decodeFromString(ImageProperties.serializer(), jsonString) + + assertEquals("https://example.com/photo.jpg", result.url) + assertEquals(CraftDContentScale.FIT, result.contentScale) + assertEquals("Photo", result.contentDescription) + } +} diff --git a/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKeyTest.kt b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKeyTest.kt index 7ff8d8f..03bf5fd 100644 --- a/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKeyTest.kt +++ b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKeyTest.kt @@ -66,10 +66,11 @@ class CraftDComponentKeyTest { @Test fun `given all enum constants when iterating then all constants are present`() { val constants = CraftDComponentKey.values() - assertEquals(3, constants.size) + assertEquals(4, constants.size) assertEquals(true, constants.contains(CraftDComponentKey.TEXT_VIEW_COMPONENT)) assertEquals(true, constants.contains(CraftDComponentKey.BUTTON_COMPONENT)) assertEquals(true, constants.contains(CraftDComponentKey.CHECK_BOX_COMPONENT)) + assertEquals(true, constants.contains(CraftDComponentKey.IMAGE_COMPONENT)) } @Test @@ -133,7 +134,7 @@ class CraftDComponentKeyTest { @Test fun `given all components when checking keys are unique then no duplicates exist`() { val keys = CraftDComponentKey.values().map { it.key }.toSet() - assertEquals(3, keys.size) + assertEquals(4, keys.size) } @Test diff --git a/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/builder/CraftDBuilderManager.kt b/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/builder/CraftDBuilderManager.kt index 11d3a32..90ae8aa 100644 --- a/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/builder/CraftDBuilderManager.kt +++ b/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/builder/CraftDBuilderManager.kt @@ -3,18 +3,22 @@ package com.github.codandotv.craftd.xml.builder import com.github.codandotv.craftd.androidcore.data.model.base.SimpleProperties import com.github.codandotv.craftd.androidcore.presentation.CraftDViewListener import com.github.codandotv.craftd.xml.ui.CraftDViewRenderer +import android.widget.ImageView import com.github.codandotv.craftd.xml.ui.button.ButtonComponentRender +import com.github.codandotv.craftd.xml.ui.image.CraftDImageComponentRender import com.github.codandotv.craftd.xml.ui.text.CraftDTextViewComponentRender object CraftDBuilderManager { fun getBuilderRenders( simpleProperties: List, customDynamicBuilderList: List> = emptyList(), - onAction: CraftDViewListener + onAction: CraftDViewListener, + imageLoader: ((url: String, imageView: ImageView) -> Unit)? = null, ): List> { - val allViewRenders = (customDynamicBuilderList + listOf( + val allViewRenders = (customDynamicBuilderList + listOfNotNull( CraftDTextViewComponentRender(onAction), - ButtonComponentRender(onAction) + ButtonComponentRender(onAction), + imageLoader?.let { CraftDImageComponentRender(it, onAction) } )) return simpleProperties.distinctBy { it.key }.mapNotNull { simpleProperties -> diff --git a/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/ui/image/CraftDImageComponent.kt b/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/ui/image/CraftDImageComponent.kt new file mode 100644 index 0000000..2702028 --- /dev/null +++ b/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/ui/image/CraftDImageComponent.kt @@ -0,0 +1,11 @@ +package com.github.codandotv.craftd.xml.ui.image + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatImageView + +class CraftDImageComponent @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : AppCompatImageView(context, attrs, defStyleAttr) diff --git a/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/ui/image/CraftDImageComponentRender.kt b/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/ui/image/CraftDImageComponentRender.kt new file mode 100644 index 0000000..6d3c779 --- /dev/null +++ b/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/ui/image/CraftDImageComponentRender.kt @@ -0,0 +1,54 @@ +package com.github.codandotv.craftd.xml.ui.image + +import android.view.ViewGroup +import android.widget.ImageView +import androidx.recyclerview.widget.RecyclerView +import com.github.codandotv.craftd.androidcore.data.convertToElement +import com.github.codandotv.craftd.androidcore.data.model.base.SimpleProperties +import com.github.codandotv.craftd.androidcore.data.model.image.ImageProperties +import com.github.codandotv.craftd.androidcore.domain.CraftDContentScale +import com.github.codandotv.craftd.androidcore.presentation.CraftDComponentKey +import com.github.codandotv.craftd.androidcore.presentation.CraftDViewListener +import com.github.codandotv.craftd.xml.ui.CraftDViewRenderer + +class CraftDImageComponentRender( + private val imageLoader: (url: String, imageView: ImageView) -> Unit, + override var onClickListener: CraftDViewListener?, +) : CraftDViewRenderer( + CraftDComponentKey.IMAGE_COMPONENT.key, + CraftDComponentKey.IMAGE_COMPONENT.ordinal +) { + + inner class ImageHolder(val imageView: CraftDImageComponent) : RecyclerView.ViewHolder(imageView) + + override fun bindView(model: SimpleProperties, holder: ImageHolder, position: Int) { + val imageProperties = model.value.convertToElement() + + holder.imageView.scaleType = imageProperties?.contentScale.toScaleType() + + imageProperties?.url?.let { url -> + imageLoader(url, holder.imageView) + } + + imageProperties?.actionProperties?.let { actionProperties -> + holder.imageView.setOnClickListener { + onClickListener?.invoke(actionProperties) + } + } + } + + override fun createViewHolder(parent: ViewGroup): ImageHolder { + return ImageHolder(CraftDImageComponent(parent.context)) + } +} + +private fun CraftDContentScale?.toScaleType(): ImageView.ScaleType = when (this) { + CraftDContentScale.CROP -> ImageView.ScaleType.CENTER_CROP + CraftDContentScale.FIT -> ImageView.ScaleType.FIT_CENTER + CraftDContentScale.FILL_BOUNDS -> ImageView.ScaleType.FIT_XY + CraftDContentScale.FILL_WIDTH -> ImageView.ScaleType.FIT_XY + CraftDContentScale.FILL_HEIGHT -> ImageView.ScaleType.FIT_XY + CraftDContentScale.INSIDE -> ImageView.ScaleType.CENTER_INSIDE + CraftDContentScale.NONE -> ImageView.ScaleType.FIT_CENTER + null -> ImageView.ScaleType.FIT_CENTER +} diff --git a/android_kmp/gradle/libs.versions.toml b/android_kmp/gradle/libs.versions.toml index 9207595..365ffa6 100644 --- a/android_kmp/gradle/libs.versions.toml +++ b/android_kmp/gradle/libs.versions.toml @@ -29,6 +29,10 @@ androidx_core_testing = "2.2.0" mockk = "1.13.12" kotlinx-coroutines-test = "1.8.1" +# Coil +coil = "2.6.0" +coil3 = "3.0.4" + # Maven Publish plugin plugin-maven = "0.28.0" @@ -59,6 +63,11 @@ compose_lifecycle = { group = "androidx.lifecycle", name = "lifecycle-runtime-co # KotlinX kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx-collections-immutable" } +# Coil +coil_compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } +coil3_compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil3" } +coil3_network_okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil3" } + # Jackson fasterxml_jackson = { group = "com.fasterxml.jackson.core", name = "jackson-core", version.ref = "jackson" } fasterxml_jackson_databind = { group = "com.fasterxml.jackson.core", name = "jackson-databind", version.ref = "jackson" } diff --git a/docs/how-to-use/compose.md b/docs/how-to-use/compose.md index 84233cc..724e33d 100644 --- a/docs/how-to-use/compose.md +++ b/docs/how-to-use/compose.md @@ -118,3 +118,65 @@ fun InitialScreen( ``` So now enjoy your component! + +--- + +## CraftDImage — Built-in image component + +`CraftDImage` is a built-in component for rendering remote images via Server Driven UI. It requires an injected `imageLoader` so the consuming app chooses the image library. + +### JSON payload + +```json +{ + "key": "CraftDImage", + "value": { + "url": "https://example.com/photo.jpg", + "contentScale": "CROP", + "contentDescription": "A description for accessibility", + "actionProperties": { + "deeplink": "myapp://detail/1", + "analytics": { + "category": "image", + "action": "tap", + "label": "banner" + } + } + } +} +``` + +Supported `contentScale` values: `CROP`, `FIT`, `FILL_BOUNDS`, `FILL_WIDTH`, `FILL_HEIGHT`, `INSIDE`, `NONE`. + +### Registering the builder (with Coil) + +`CraftDImageBuilder` is **not** pre-registered in `CraftDBuilderManager` because it requires an `imageLoader` lambda injected by the consumer. + +```kotlin +// build.gradle.kts +implementation("io.coil-kt:coil-compose:2.6.0") +``` + +```kotlin +@Composable +fun InitialScreen(vm: SampleViewModel) { + val craftdBuilderManager = remember { + CraftDBuilderManager().add( + CraftDImageBuilder( + imageLoader = { url, contentDescription, modifier -> + AsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + ) + } + ) + ) + } + + CraftDynamic( + properties = properties, + craftDBuilderManager = craftdBuilderManager, + ) { action -> /* handle action */ } +} +``` diff --git a/docs/how-to-use/view-system.md b/docs/how-to-use/view-system.md index 3101455..23e0b19 100644 --- a/docs/how-to-use/view-system.md +++ b/docs/how-to-use/view-system.md @@ -143,4 +143,43 @@ class DynamicViewModel( } ``` -So now enjoy your component!!! \ No newline at end of file +So now enjoy your component!!! + +--- + +## CraftDImage — Built-in image component + +`CraftDImage` is a built-in component for rendering remote images via Server Driven UI. The `CraftDImageComponentRender` accepts an injected `imageLoader` lambda so the consuming app picks the image library. + +### JSON payload + +```json +{ + "key": "CraftDImage", + "value": { + "url": "https://example.com/photo.jpg", + "contentDescription": "A description for accessibility", + "actionProperties": { + "deeplink": "myapp://detail/1" + } + } +} +``` + +### Registering the render (with Picasso) + +Pass `imageLoader` to `CraftDBuilderManager.getBuilderRenders()`: + +```kotlin +private fun setupDynamicRender(list: List) { + craft.registerRenderers( + CraftDBuilderManager.getBuilderRenders( + simpleProperties = list, + onAction = { action -> listener.invoke(action) }, + imageLoader = { url, imageView -> + Picasso.get().load(url).into(imageView) + } + ) + ) +} +``` \ No newline at end of file diff --git a/openspec/changes/add-craftd-image/notes.md b/openspec/changes/add-craftd-image/notes.md deleted file mode 100644 index b39c458..0000000 --- a/openspec/changes/add-craftd-image/notes.md +++ /dev/null @@ -1,16 +0,0 @@ -# Context: add-craftd-image - -Baseado no PR #78 (https://github.com/CodandoTV/CraftD/pull/78) que ficou incompleto. - -## O que deve ser implementado - -Componente `CraftDImage` para suporte a imagens locais e de rede nas plataformas Android Compose, KMP e XML. - -## Requisitos derivados do review do PR #78 - -- **Abstração do loader**: não acoplar Coil diretamente no builder. Expor parâmetro `imageLoader` no construtor do `CraftDImageBuilder` para o consumidor injetar a implementação (regra 10 do CLAUDE.md) -- **Registro no CraftDBuilderManager Compose/KMP**: `CraftDComponentKey.IMAGE_COMPONENT.key to CraftDImageBuilder()` -- **Registro no CraftDBuilderManager XML**: adicionar `ImageComponentRender` em `getBuilderRenders()` -- **Testes unitários**: incluir testes para `toContentScale()` (torná-la `internal`) -- **Evitar duplicação commonMain/androidMain**: manter implementação apenas em `commonMain` salvo necessidade real de `expect/actual` -- Coil 3 com suporte multiplatforma é a lib de referência, mas deve ser injetada, não acoplada diff --git a/openspec/changes/archive/2026-04-13-add-craftd-image-android-kmp/tasks.md b/openspec/changes/archive/2026-04-13-add-craftd-image-android-kmp/tasks.md new file mode 100644 index 0000000..5d7acce --- /dev/null +++ b/openspec/changes/archive/2026-04-13-add-craftd-image-android-kmp/tasks.md @@ -0,0 +1,44 @@ +## 1. craftd-core: Model and enum + +- [x] 1.1 Add `IMAGE_COMPONENT("CraftDImage")` to `CraftDComponentKey` enum +- [x] 1.2 Create `CraftDContentScale` enum in `craftd-core/commonMain/domain/` with values: `CROP`, `FIT`, `FILL_BOUNDS`, `FILL_WIDTH`, `FILL_HEIGHT`, `INSIDE`, `NONE` +- [x] 1.3 Create `ImageProperties` data class in `craftd-core/commonMain/data/model/image/ImageProperties.kt` with fields: `url`, `contentScale`, `contentDescription`, `actionProperties` +- [x] 1.4 *(fix PR review)* Remove `@Stable`/`@Immutable` from `ImageProperties` — anotações `androidx.compose.runtime` proibidas em `commonMain` (regra 4) + +## 2. craftd-compose: Composable and builder + +- [x] 2.1 Create `toContentScale()` internal extension function in `craftd-compose/commonMain` mapping `CraftDContentScale` → `ContentScale` +- [x] 2.2 Create `CraftDImage` composable in `craftd-compose/commonMain/ui/image/CraftDImage.kt` accepting `ImageProperties`, `imageLoader` lambda (com `ContentScale`), `onAction` callback, e `modifier` +- [x] 2.2a *(fix PR review)* `imageLoader` lambda deve incluir `contentScale: ContentScale` como parâmetro e `CraftDImage` deve passar `properties.contentScale.toContentScale()` — antes o `contentScale` era ignorado silenciosamente +- [x] 2.3 Create `CraftDImageBuilder` in `craftd-compose/commonMain/ui/image/CraftDImageBuilder.kt` with injectable `imageLoader` constructor parameter +- [x] 2.4 `CraftDImageBuilder` not pre-registered in `CraftDBuilderManager` by design — requires `imageLoader` injection by the consumer via `builderManager.add(CraftDImageBuilder(imageLoader))` + +## 3. craftd-xml: Component and render + +- [x] 3.1 Create `CraftDImageComponent` (custom View or standard `ImageView` wrapper) in `craftd-xml/src/main/kotlin/.../ui/image/` +- [x] 3.2 Create `CraftDImageComponentRender` in `craftd-xml/src/main/kotlin/.../ui/image/CraftDImageComponentRender.kt` with injectable `imageLoader: (url: String, imageView: ImageView) -> Unit` +- [x] 3.2a *(fix PR review)* Mapear `contentScale` → `ImageView.scaleType` via `toScaleType()` em `bindView` antes de chamar `imageLoader` — antes o `contentScale` era ignorado no XML +- [x] 3.3 Register `CraftDImageComponentRender` in `craftd-xml`'s `CraftDBuilderManager.getBuilderRenders()` + +## 4. Tests + +- [x] 4.1 Unit test for `ImageProperties` serialization/deserialization (craftd-core) +- [x] 4.2 Unit test for `toContentScale()` covering all `CraftDContentScale` values +- [x] 4.3 Unit test para `CraftDImageBuilder` — verifica `key` e override de `key` +- [ ] 4.4 *(pendente — novo propose)* Complementar testes: verificar que `imageLoader` é chamado com args corretos (url, contentDescription, contentScale) e que `listener.invoke(actionProperties)` é disparado; adicionar testes para `CraftDImageComponentRender` (XML) + +## 5. Documentation + +- [x] 5.1 Update `docs/how-to-use/compose.md` with `CraftDImage` usage example (including imageLoader injection with Coil) +- [x] 5.2 Update `docs/how-to-use/view-system.md` with `CraftDImageComponentRender` usage example + +## 6. Sample app + +- [x] 6.1 Register `CraftDImageBuilder` (with Coil imageLoader) in `app-sample-android` Compose setup +- [x] 6.2 Add image entry to the mock/sample JSON in `app-sample-android` so the component is visible na tela Compose +- [x] 6.3 Register `CraftDImageComponentRender` (with Picasso imageLoader) in `app-sample-android` XML setup +- [x] 6.4 Add image entry to the mock/sample JSON in `app-sample-android` so the component is visible na tela XML + +## 7. Cleanup + +- [x] 7.1 Delete `openspec/changes/add-craftd-image-android-kmp/notes.md` (context consumed) diff --git a/openspec/changes/add-craftd-image/.openspec.yaml b/openspec/changes/archive/2026-04-22-add-craftd-image/.openspec.yaml similarity index 100% rename from openspec/changes/add-craftd-image/.openspec.yaml rename to openspec/changes/archive/2026-04-22-add-craftd-image/.openspec.yaml