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.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/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..de93e8a 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,15 @@ fun InitialScreen( val craftdBuilderManager = remember { CraftDBuilderManager().add( MySampleButtonComposeBuilder(), + CraftDImageBuilder( + imageLoader = { url, contentDescription, modifier -> + AsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + ) + } + ), ) } 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/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..38abcfd --- /dev/null +++ b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImage.kt @@ -0,0 +1,24 @@ +package com.github.codandotv.craftd.compose.ui.image + +import androidx.compose.foundation.clickable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.github.codandotv.craftd.androidcore.data.model.image.ImageProperties + +@Composable +fun CraftDImage( + properties: ImageProperties, + imageLoader: @Composable (url: String, contentDescription: String?, modifier: Modifier) -> Unit, + onAction: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + val clickableModifier = if (properties.actionProperties != null) { + modifier.clickable { onAction() } + } else modifier + + imageLoader( + properties.url.orEmpty(), + properties.contentDescription, + clickableModifier, + ) +} 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..4f43b19 --- /dev/null +++ b/android_kmp/craftd-compose/src/commonMain/kotlin/com/github/codandotv/craftd/compose/ui/image/CraftDImageBuilder.kt @@ -0,0 +1,27 @@ +package com.github.codandotv.craftd.compose.ui.image + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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) -> 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..9c6b4ed --- /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 70fc7b5..e265eb3 100644 --- a/android_kmp/craftd-core/build.gradle.kts +++ b/android_kmp/craftd-core/build.gradle.kts @@ -25,5 +25,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..7b828e9 --- /dev/null +++ b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/data/model/image/ImageProperties.kt @@ -0,0 +1,18 @@ +package com.github.codandotv.craftd.androidcore.data.model.image + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +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 +@Immutable +@Stable +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..7f8aa9c --- /dev/null +++ b/android_kmp/craftd-core/src/commonMain/kotlin/com/github/codandotv/craftd/androidcore/domain/CraftDContentScale.kt @@ -0,0 +1,16 @@ +package com.github.codandotv.craftd.androidcore.domain + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +@Stable +@Immutable +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/data/model/image/ImagePropertiesTest.kt b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/data/model/image/ImagePropertiesTest.kt new file mode 100644 index 0000000..0dad1a1 --- /dev/null +++ b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/data/model/image/ImagePropertiesTest.kt @@ -0,0 +1,289 @@ +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 io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ImagePropertiesTest { + + @Test + fun `given all parameters when creating ImageProperties then all fields are set`() { + val url = "https://example.com/image.png" + val contentScale = CraftDContentScale.CROP + val contentDescription = "Test image" + val actionProperties = mockk() + + val imageProperties = ImageProperties( + url = url, + contentScale = contentScale, + contentDescription = contentDescription, + actionProperties = actionProperties + ) + + assertEquals(url, imageProperties.url) + assertEquals(contentScale, imageProperties.contentScale) + assertEquals(contentDescription, imageProperties.contentDescription) + assertEquals(actionProperties, imageProperties.actionProperties) + } + + @Test + fun `given default values when creating ImageProperties then all fields are null`() { + val imageProperties = ImageProperties() + + assertEquals(null, imageProperties.url) + assertEquals(null, imageProperties.contentScale) + assertEquals(null, imageProperties.contentDescription) + assertEquals(null, imageProperties.actionProperties) + } + + @Test + fun `given partial parameters when creating ImageProperties then only specified fields are set`() { + val url = "https://example.com/image.png" + val contentDescription = "Test image" + + val imageProperties = ImageProperties( + url = url, + contentDescription = contentDescription + ) + + assertEquals(url, imageProperties.url) + assertEquals(null, imageProperties.contentScale) + assertEquals(contentDescription, imageProperties.contentDescription) + assertEquals(null, imageProperties.actionProperties) + } + + @Test + fun `given ImageProperties when calling copy with new url then returns new instance with updated url`() { + val original = ImageProperties( + url = "https://example.com/image1.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "Original" + ) + val newUrl = "https://example.com/image2.png" + + val copied = original.copy(url = newUrl) + + assertEquals(newUrl, copied.url) + assertEquals(original.contentScale, copied.contentScale) + assertEquals(original.contentDescription, copied.contentDescription) + assertNotEquals(original, copied) + } + + @Test + fun `given ImageProperties when calling copy with new contentScale then returns new instance with updated contentScale`() { + val original = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP + ) + val newContentScale = CraftDContentScale.FIT + + val copied = original.copy(contentScale = newContentScale) + + assertEquals(original.url, copied.url) + assertEquals(newContentScale, copied.contentScale) + assertNotEquals(original, copied) + } + + @Test + fun `given ImageProperties when calling copy with new contentDescription then returns new instance with updated contentDescription`() { + val original = ImageProperties( + url = "https://example.com/image.png", + contentDescription = "Original" + ) + val newDescription = "Updated" + + val copied = original.copy(contentDescription = newDescription) + + assertEquals(original.url, copied.url) + assertEquals(newDescription, copied.contentDescription) + assertNotEquals(original, copied) + } + + @Test + fun `given ImageProperties when calling copy with new actionProperties then returns new instance with updated actionProperties`() { + val actionProperties1 = mockk() + val actionProperties2 = mockk() + val original = ImageProperties(actionProperties = actionProperties1) + + val copied = original.copy(actionProperties = actionProperties2) + + assertEquals(actionProperties2, copied.actionProperties) + assertNotEquals(original, copied) + } + + @Test + fun `given two ImageProperties with same values when comparing then equals returns true`() { + val imageProperties1 = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "Test", + actionProperties = null + ) + val imageProperties2 = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "Test", + actionProperties = null + ) + + assertEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given two ImageProperties with different urls when comparing then equals returns false`() { + val imageProperties1 = ImageProperties(url = "https://example.com/image1.png") + val imageProperties2 = ImageProperties(url = "https://example.com/image2.png") + + assertNotEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given two ImageProperties with different contentScale when comparing then equals returns false`() { + val imageProperties1 = ImageProperties(contentScale = CraftDContentScale.CROP) + val imageProperties2 = ImageProperties(contentScale = CraftDContentScale.FIT) + + assertNotEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given two ImageProperties with different contentDescription when comparing then equals returns false`() { + val imageProperties1 = ImageProperties(contentDescription = "Description 1") + val imageProperties2 = ImageProperties(contentDescription = "Description 2") + + assertNotEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given two ImageProperties with same values when calling hashCode then returns same hash`() { + val imageProperties1 = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "Test" + ) + val imageProperties2 = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "Test" + ) + + assertEquals(imageProperties1.hashCode(), imageProperties2.hashCode()) + } + + @Test + fun `given two ImageProperties with different values when calling hashCode then likely returns different hash`() { + val imageProperties1 = ImageProperties(url = "https://example.com/image1.png") + val imageProperties2 = ImageProperties(url = "https://example.com/image2.png") + + assertNotEquals(imageProperties1.hashCode(), imageProperties2.hashCode()) + } + + @Test + fun `given ImageProperties when calling toString then contains all field values`() { + val imageProperties = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "Test" + ) + + val stringRepresentation = imageProperties.toString() + + assert(stringRepresentation.contains("url")) + assert(stringRepresentation.contains("https://example.com/image.png")) + } + + @Test + fun `given ImageProperties with null url when accessing url then returns null`() { + val imageProperties = ImageProperties(url = null) + + assertEquals(null, imageProperties.url) + } + + @Test + fun `given ImageProperties with null contentScale when accessing contentScale then returns null`() { + val imageProperties = ImageProperties(contentScale = null) + + assertEquals(null, imageProperties.contentScale) + } + + @Test + fun `given ImageProperties with null contentDescription when accessing contentDescription then returns null`() { + val imageProperties = ImageProperties(contentDescription = null) + + assertEquals(null, imageProperties.contentDescription) + } + + @Test + fun `given ImageProperties with null actionProperties when accessing actionProperties then returns null`() { + val imageProperties = ImageProperties(actionProperties = null) + + assertEquals(null, imageProperties.actionProperties) + } + + @Test + fun `given ImageProperties with empty string url when creating then url is empty string`() { + val imageProperties = ImageProperties(url = "") + + assertEquals("", imageProperties.url) + } + + @Test + fun `given ImageProperties with empty string contentDescription when creating then contentDescription is empty string`() { + val imageProperties = ImageProperties(contentDescription = "") + + assertEquals("", imageProperties.contentDescription) + } + + @Test + fun `given two identical ImageProperties when calling copy on one then original remains unchanged`() { + val original = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.CROP + ) + val originalUrl = original.url + val originalContentScale = original.contentScale + + original.copy(url = "https://example.com/other.png") + + assertEquals(originalUrl, original.url) + assertEquals(originalContentScale, original.contentScale) + } + + @Test + fun `given ImageProperties with actionProperties when comparing two instances then equals considers actionProperties`() { + val actionProperties = mockk() + val imageProperties1 = ImageProperties(actionProperties = actionProperties) + val imageProperties2 = ImageProperties(actionProperties = actionProperties) + + assertEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given multiple field changes when calling copy with all parameters then returns new instance with all updates`() { + val original = ImageProperties( + url = "https://example.com/image1.png", + contentScale = CraftDContentScale.CROP, + contentDescription = "Original", + actionProperties = null + ) + val newActionProperties = mockk() + + val copied = original.copy( + url = "https://example.com/image2.png", + contentScale = CraftDContentScale.FIT, + contentDescription = "Updated", + actionProperties = newActionProperties + ) + + assertEquals("https://example.com/image2.png", copied.url) + assertEquals(CraftDContentScale.FIT, copied.contentScale) + assertEquals("Updated", copied.contentDescription) + assertEquals(newActionProperties, copied.actionProperties) + assertNotEquals(original, copied) + } +} \ No newline at end of file diff --git a/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/domain/CraftDContentScaleTest.kt b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/domain/CraftDContentScaleTest.kt new file mode 100644 index 0000000..91bf58d --- /dev/null +++ b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/domain/CraftDContentScaleTest.kt @@ -0,0 +1,220 @@ +package com.github.codandotv.craftd.androidcore.domain + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@RunWith(JUnit4::class) +class CraftDContentScaleTest { + + @Test + fun `given CraftDContentScale enum when accessing CROP then returns CROP constant`() { + val result = CraftDContentScale.CROP + assertNotNull(result) + assertEquals(CraftDContentScale.CROP, result) + assertEquals("CROP", result.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FIT then returns FIT constant`() { + val result = CraftDContentScale.FIT + assertNotNull(result) + assertEquals(CraftDContentScale.FIT, result) + assertEquals("FIT", result.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FILL_BOUNDS then returns FILL_BOUNDS constant`() { + val result = CraftDContentScale.FILL_BOUNDS + assertNotNull(result) + assertEquals(CraftDContentScale.FILL_BOUNDS, result) + assertEquals("FILL_BOUNDS", result.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FILL_WIDTH then returns FILL_WIDTH constant`() { + val result = CraftDContentScale.FILL_WIDTH + assertNotNull(result) + assertEquals(CraftDContentScale.FILL_WIDTH, result) + assertEquals("FILL_WIDTH", result.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FILL_HEIGHT then returns FILL_HEIGHT constant`() { + val result = CraftDContentScale.FILL_HEIGHT + assertNotNull(result) + assertEquals(CraftDContentScale.FILL_HEIGHT, result) + assertEquals("FILL_HEIGHT", result.name) + } + + @Test + fun `given CraftDContentScale enum when accessing INSIDE then returns INSIDE constant`() { + val result = CraftDContentScale.INSIDE + assertNotNull(result) + assertEquals(CraftDContentScale.INSIDE, result) + assertEquals("INSIDE", result.name) + } + + @Test + fun `given CraftDContentScale enum when accessing NONE then returns NONE constant`() { + val result = CraftDContentScale.NONE + assertNotNull(result) + assertEquals(CraftDContentScale.NONE, result) + assertEquals("NONE", result.name) + } + + @Test + fun `given CraftDContentScale enum when calling values then returns all constants`() { + val values = CraftDContentScale.values() + assertEquals(7, values.size) + assertEquals( + setOf( + CraftDContentScale.CROP, + CraftDContentScale.FIT, + CraftDContentScale.FILL_BOUNDS, + CraftDContentScale.FILL_WIDTH, + CraftDContentScale.FILL_HEIGHT, + CraftDContentScale.INSIDE, + CraftDContentScale.NONE + ), + values.toSet() + ) + } + + @Test + fun `given CraftDContentScale enum when calling valueOf with CROP string then returns CROP constant`() { + val result = enumValueOf("CROP") + assertEquals(CraftDContentScale.CROP, result) + } + + @Test + fun `given CraftDContentScale enum when calling valueOf with FIT string then returns FIT constant`() { + val result = enumValueOf("FIT") + assertEquals(CraftDContentScale.FIT, result) + } + + @Test + fun `given CraftDContentScale enum when calling valueOf with FILL_BOUNDS string then returns FILL_BOUNDS constant`() { + val result = enumValueOf("FILL_BOUNDS") + assertEquals(CraftDContentScale.FILL_BOUNDS, result) + } + + @Test + fun `given CraftDContentScale enum when calling valueOf with FILL_WIDTH string then returns FILL_WIDTH constant`() { + val result = enumValueOf("FILL_WIDTH") + assertEquals(CraftDContentScale.FILL_WIDTH, result) + } + + @Test + fun `given CraftDContentScale enum when calling valueOf with FILL_HEIGHT string then returns FILL_HEIGHT constant`() { + val result = enumValueOf("FILL_HEIGHT") + assertEquals(CraftDContentScale.FILL_HEIGHT, result) + } + + @Test + fun `given CraftDContentScale enum when calling valueOf with INSIDE string then returns INSIDE constant`() { + val result = enumValueOf("INSIDE") + assertEquals(CraftDContentScale.INSIDE, result) + } + + @Test + fun `given CraftDContentScale enum when calling valueOf with NONE string then returns NONE constant`() { + val result = enumValueOf("NONE") + assertEquals(CraftDContentScale.NONE, result) + } + + @Test + fun `given CraftDContentScale enum when comparing two CROP constants then they are equal`() { + val first = CraftDContentScale.CROP + val second = CraftDContentScale.CROP + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + } + + @Test + fun `given CraftDContentScale enum when comparing CROP and FIT then they are not equal`() { + val crop = CraftDContentScale.CROP + val fit = CraftDContentScale.FIT + assertNotNull(crop) + assertNotNull(fit) + } + + @Test + fun `given CraftDContentScale enum when checking ordinal of CROP then returns 0`() { + assertEquals(0, CraftDContentScale.CROP.ordinal) + } + + @Test + fun `given CraftDContentScale enum when checking ordinal of FIT then returns 1`() { + assertEquals(1, CraftDContentScale.FIT.ordinal) + } + + @Test + fun `given CraftDContentScale enum when checking ordinal of FILL_BOUNDS then returns 2`() { + assertEquals(2, CraftDContentScale.FILL_BOUNDS.ordinal) + } + + @Test + fun `given CraftDContentScale enum when checking ordinal of FILL_WIDTH then returns 3`() { + assertEquals(3, CraftDContentScale.FILL_WIDTH.ordinal) + } + + @Test + fun `given CraftDContentScale enum when checking ordinal of FILL_HEIGHT then returns 4`() { + assertEquals(4, CraftDContentScale.FILL_HEIGHT.ordinal) + } + + @Test + fun `given CraftDContentScale enum when checking ordinal of INSIDE then returns 5`() { + assertEquals(5, CraftDContentScale.INSIDE.ordinal) + } + + @Test + fun `given CraftDContentScale enum when checking ordinal of NONE then returns 6`() { + assertEquals(6, CraftDContentScale.NONE.ordinal) + } + + @Test + fun `given CraftDContentScale enum when calling toString on CROP then returns CROP string`() { + val result = CraftDContentScale.CROP.toString() + assertEquals("CROP", result) + } + + @Test + fun `given CraftDContentScale enum when calling toString on FIT then returns FIT string`() { + val result = CraftDContentScale.FIT.toString() + assertEquals("FIT", result) + } + + @Test + fun `given CraftDContentScale enum when calling toString on FILL_BOUNDS then returns FILL_BOUNDS string`() { + val result = CraftDContentScale.FILL_BOUNDS.toString() + assertEquals("FILL_BOUNDS", result) + } + + @Test + fun `given CraftDContentScale enum when calling toString on FILL_WIDTH then returns FILL_WIDTH string`() { + val result = CraftDContentScale.FILL_WIDTH.toString() + assertEquals("FILL_WIDTH", result) + } + + @Test + fun `given CraftDContentScale enum when calling toString on FILL_HEIGHT then returns FILL_HEIGHT string`() { + val result = CraftDContentScale.FILL_HEIGHT.toString() + assertEquals("FILL_HEIGHT", result) + } + + @Test + fun `given CraftDContentScale enum when calling toString on INSIDE then returns INSIDE string`() { + val result = CraftDContentScale.INSIDE.toString() + assertEquals("INSIDE", result) + } + + @Test + fun `given CraftDContentScale enum when calling toString on NONE then returns NONE string`() { + val result = CraftDContentScale.NONE.toString() + assertEquals("NONE", result) + } +} \ No newline at end of file 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 new file mode 100644 index 0000000..4e84956 --- /dev/null +++ b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKeyTest.kt @@ -0,0 +1,158 @@ +package com.github.codandotv.craftd.androidcore.presentation + +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +@RunWith(JUnit4::class) +class CraftDComponentKeyTest { + + @Test + fun `given TEXT_VIEW_COMPONENT enum constant when accessing key then returns correct string value`() { + val component = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals("CraftDTextView", component.key) + } + + @Test + fun `given BUTTON_COMPONENT enum constant when accessing key then returns correct string value`() { + val component = CraftDComponentKey.BUTTON_COMPONENT + assertEquals("CraftDButton", component.key) + } + + @Test + fun `given CHECK_BOX_COMPONENT enum constant when accessing key then returns correct string value`() { + val component = CraftDComponentKey.CHECK_BOX_COMPONENT + assertEquals("CraftDCheckBox", component.key) + } + + @Test + fun `given IMAGE_COMPONENT enum constant when accessing key then returns correct string value`() { + val component = CraftDComponentKey.IMAGE_COMPONENT + assertEquals("CraftDImage", component.key) + } + + @Test + fun `given all enum constants exist when using enumValueOf then succeeds for each constant name`() { + val textView = enumValueOf("TEXT_VIEW_COMPONENT") + assertEquals(CraftDComponentKey.TEXT_VIEW_COMPONENT, textView) + + val button = enumValueOf("BUTTON_COMPONENT") + assertEquals(CraftDComponentKey.BUTTON_COMPONENT, button) + + val checkBox = enumValueOf("CHECK_BOX_COMPONENT") + assertEquals(CraftDComponentKey.CHECK_BOX_COMPONENT, checkBox) + + val image = enumValueOf("IMAGE_COMPONENT") + assertEquals(CraftDComponentKey.IMAGE_COMPONENT, image) + } + + @Test + fun `given invalid enum constant name when using enumValueOf then throws IllegalArgumentException`() { + assertFailsWith { + enumValueOf("INVALID_COMPONENT") + } + } + + @Test + fun `given TEXT_VIEW_COMPONENT when comparing with TEXT_VIEW_COMPONENT then returns equal`() { + val component1 = CraftDComponentKey.TEXT_VIEW_COMPONENT + val component2 = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals(component1, component2) + } + + @Test + fun `given TEXT_VIEW_COMPONENT when comparing with BUTTON_COMPONENT then returns not equal`() { + val component1 = CraftDComponentKey.TEXT_VIEW_COMPONENT + val component2 = CraftDComponentKey.BUTTON_COMPONENT + assertNotEquals(component1, component2) + } + + @Test + fun `given TEXT_VIEW_COMPONENT when getting hashCode then returns consistent value`() { + val component = CraftDComponentKey.TEXT_VIEW_COMPONENT + val hashCode1 = component.hashCode() + val hashCode2 = component.hashCode() + assertEquals(hashCode1, hashCode2) + } + + @Test + fun `given two equal enum constants when comparing hashCode then returns same hash`() { + val component1 = CraftDComponentKey.TEXT_VIEW_COMPONENT + val component2 = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals(component1.hashCode(), component2.hashCode()) + } + + @Test + fun `given CRAFT_D constant when accessing value then returns CraftD string`() { + assertEquals("CraftD", CRAFT_D) + } + + @Test + fun `given enum when calling values then returns all four constants`() { + val values = CraftDComponentKey.values() + assertEquals(4, values.size) + assertEquals( + setOf( + CraftDComponentKey.TEXT_VIEW_COMPONENT, + CraftDComponentKey.BUTTON_COMPONENT, + CraftDComponentKey.CHECK_BOX_COMPONENT, + CraftDComponentKey.IMAGE_COMPONENT + ), + values.toSet() + ) + } + + @Test + fun `given TEXT_VIEW_COMPONENT when accessing ordinal then returns zero`() { + val component = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals(0, component.ordinal) + } + + @Test + fun `given BUTTON_COMPONENT when accessing ordinal then returns one`() { + val component = CraftDComponentKey.BUTTON_COMPONENT + assertEquals(1, component.ordinal) + } + + @Test + fun `given CHECK_BOX_COMPONENT when accessing ordinal then returns two`() { + val component = CraftDComponentKey.CHECK_BOX_COMPONENT + assertEquals(2, component.ordinal) + } + + @Test + fun `given IMAGE_COMPONENT when accessing ordinal then returns three`() { + val component = CraftDComponentKey.IMAGE_COMPONENT + assertEquals(3, component.ordinal) + } + + @Test + fun `given TEXT_VIEW_COMPONENT when accessing name then returns TEXT_VIEW_COMPONENT`() { + val component = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals("TEXT_VIEW_COMPONENT", component.name) + } + + @Test + fun `given BUTTON_COMPONENT when accessing name then returns BUTTON_COMPONENT`() { + val component = CraftDComponentKey.BUTTON_COMPONENT + assertEquals("BUTTON_COMPONENT", component.name) + } + + @Test + fun `given CHECK_BOX_COMPONENT when accessing name then returns CHECK_BOX_COMPONENT`() { + val component = CraftDComponentKey.CHECK_BOX_COMPONENT + assertEquals("CHECK_BOX_COMPONENT", component.name) + } + + @Test + fun `given IMAGE_COMPONENT when accessing name then returns IMAGE_COMPONENT`() { + val component = CraftDComponentKey.IMAGE_COMPONENT + assertEquals("IMAGE_COMPONENT", component.name) + } + + private fun assertNotEquals(expected: Any, actual: Any) { + assert(expected != actual) { "Expected $expected to not equal $actual" } + } +} \ No newline at end of file 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..2a38678 --- /dev/null +++ b/android_kmp/craftd-xml/src/main/kotlin/com/github/codandotv/craftd/xml/ui/image/CraftDImageComponentRender.kt @@ -0,0 +1,40 @@ +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.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() + + 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)) + } +} diff --git a/android_kmp/gradle/libs.versions.toml b/android_kmp/gradle/libs.versions.toml index 9207595..d0a6e05 100644 --- a/android_kmp/gradle/libs.versions.toml +++ b/android_kmp/gradle/libs.versions.toml @@ -29,6 +29,9 @@ androidx_core_testing = "2.2.0" mockk = "1.13.12" kotlinx-coroutines-test = "1.8.1" +# Coil +coil = "2.6.0" + # Maven Publish plugin plugin-maven = "0.28.0" @@ -59,6 +62,9 @@ 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" } + # 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-android-kmp/tasks.md b/openspec/changes/add-craftd-image-android-kmp/tasks.md new file mode 100644 index 0000000..3a47164 --- /dev/null +++ b/openspec/changes/add-craftd-image-android-kmp/tasks.md @@ -0,0 +1,40 @@ +## 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` + +## 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, `onAction` callback, and `modifier` +- [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.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 for `CraftDImageBuilder` — verify `imageLoader` is called with correct args and `actionProperties` triggers listener + +## 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/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