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..851b4bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,108 +54,117 @@ docs/ # documentação do site (MkDocs) 10. **Dependências de bibliotecas externas devem ser abstraídas.** Nunca acoplar diretamente uma lib de terceiro (ex: Coil, Picasso, Glide) dentro do builder. Expor uma interface/função como parâmetro do construtor para que o consumidor injete a implementação. +11. **Todo novo componente deve ser demonstrado no app de exemplo da respectiva plataforma.** Para Android Compose: `app-sample-android/` (registrar o builder no `CraftDBuilderManager` do sample e incluir o componente no JSON de mock/tela de exemplo). Para XML: idem na Activity XML do sample. Nunca criar um componente sem adicionar um exemplo funcional no sample correspondente. + --- -## 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..a4e040f --- /dev/null +++ b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/data/model/image/ImagePropertiesTest.kt @@ -0,0 +1,378 @@ +package com.github.codandotv.craftd.androidcore.data.model.image + +import io.mockk.mockk +import io.mockk.verify +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import com.github.codandotv.craftd.androidcore.domain.CraftDContentScale +import com.github.codandotv.craftd.androidcore.data.model.action.ActionProperties + +@RunWith(JUnit4::class) +class ImagePropertiesTest { + + private lateinit var actionPropertiesMock: ActionProperties + + @Before + fun setUp() { + actionPropertiesMock = mockk() + } + + @Test + fun `given all parameters when constructing ImageProperties then all fields are set correctly`() { + val url = "https://example.com/image.png" + val contentScale = CraftDContentScale.Crop + val contentDescription = "Test image" + val actionProperties = actionPropertiesMock + + 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 no parameters when constructing ImageProperties then all fields are null`() { + val imageProperties = ImageProperties() + + assertNull(imageProperties.url) + assertNull(imageProperties.contentScale) + assertNull(imageProperties.contentDescription) + assertNull(imageProperties.actionProperties) + } + + @Test + fun `given partial parameters when constructing 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) + assertNull(imageProperties.contentScale) + assertEquals(contentDescription, imageProperties.contentDescription) + assertNull(imageProperties.actionProperties) + } + + @Test + fun `given ImageProperties when copying with new url then new instance has updated url`() { + val original = ImageProperties( + url = "https://example.com/image1.png", + contentScale = CraftDContentScale.Crop, + contentDescription = "Original image", + actionProperties = actionPropertiesMock + ) + 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) + assertEquals(original.actionProperties, copied.actionProperties) + } + + @Test + fun `given ImageProperties when copying with new contentScale then new instance has 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) + } + + @Test + fun `given ImageProperties when copying with new contentDescription then new instance has updated contentDescription`() { + val original = ImageProperties( + contentDescription = "Original description" + ) + val newDescription = "New description" + + val copied = original.copy(contentDescription = newDescription) + + assertEquals(newDescription, copied.contentDescription) + } + + @Test + fun `given ImageProperties when copying with new actionProperties then new instance has updated actionProperties`() { + val original = ImageProperties( + actionProperties = actionPropertiesMock + ) + val newActionProperties = mockk() + + val copied = original.copy(actionProperties = newActionProperties) + + assertEquals(newActionProperties, copied.actionProperties) + } + + @Test + fun `given ImageProperties when copying all fields then new instance has all updated values`() { + val original = ImageProperties( + url = "https://example.com/image1.png", + contentScale = CraftDContentScale.Crop, + contentDescription = "Original", + actionProperties = actionPropertiesMock + ) + val newUrl = "https://example.com/image2.png" + val newContentScale = CraftDContentScale.Fit + val newDescription = "New" + val newActionProperties = mockk() + + val copied = original.copy( + url = newUrl, + contentScale = newContentScale, + contentDescription = newDescription, + actionProperties = newActionProperties + ) + + assertEquals(newUrl, copied.url) + assertEquals(newContentScale, copied.contentScale) + assertEquals(newDescription, copied.contentDescription) + assertEquals(newActionProperties, copied.actionProperties) + } + + @Test + fun `given two identical ImageProperties when comparing then equals returns true`() { + val url = "https://example.com/image.png" + val contentScale = CraftDContentScale.Crop + val description = "Test image" + + val imageProperties1 = ImageProperties( + url = url, + contentScale = contentScale, + contentDescription = description, + actionProperties = actionPropertiesMock + ) + val imageProperties2 = ImageProperties( + url = url, + contentScale = contentScale, + contentDescription = description, + actionProperties = actionPropertiesMock + ) + + assertEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given two ImageProperties with different url 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 different actionProperties when comparing then equals returns false`() { + val actionProperties1 = mockk() + val actionProperties2 = mockk() + + val imageProperties1 = ImageProperties(actionProperties = actionProperties1) + val imageProperties2 = ImageProperties(actionProperties = actionProperties2) + + assertNotEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given two identical ImageProperties when calculating hashCode then hashCodes are equal`() { + val url = "https://example.com/image.png" + val contentScale = CraftDContentScale.Crop + val description = "Test image" + + val imageProperties1 = ImageProperties( + url = url, + contentScale = contentScale, + contentDescription = description, + actionProperties = actionPropertiesMock + ) + val imageProperties2 = ImageProperties( + url = url, + contentScale = contentScale, + contentDescription = description, + actionProperties = actionPropertiesMock + ) + + assertEquals(imageProperties1.hashCode(), imageProperties2.hashCode()) + } + + @Test + fun `given two different ImageProperties when calculating hashCode then hashCodes are different`() { + 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 with all null fields when converting to string then toString returns valid representation`() { + val imageProperties = ImageProperties() + val stringRepresentation = imageProperties.toString() + + assertTrue(stringRepresentation.contains("ImageProperties")) + assertTrue(stringRepresentation.contains("url=null")) + } + + @Test + fun `given ImageProperties with all fields set when converting to string then toString includes all field values`() { + val url = "https://example.com/image.png" + val contentScale = CraftDContentScale.Crop + val description = "Test image" + + val imageProperties = ImageProperties( + url = url, + contentScale = contentScale, + contentDescription = description, + actionProperties = actionPropertiesMock + ) + val stringRepresentation = imageProperties.toString() + + assertTrue(stringRepresentation.contains("ImageProperties")) + assertTrue(stringRepresentation.contains(url)) + assertTrue(stringRepresentation.contains(contentScale.toString())) + assertTrue(stringRepresentation.contains(description)) + } + + @Test + fun `given ImageProperties when verifying data class properties then all properties are accessible`() { + val imageProperties = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.Crop, + contentDescription = "Test", + actionProperties = actionPropertiesMock + ) + + assertTrue(imageProperties.url != null) + assertTrue(imageProperties.contentScale != null) + assertTrue(imageProperties.contentDescription != null) + assertTrue(imageProperties.actionProperties != null) + } + + @Test + fun `given ImageProperties with null actionProperties when checking actionProperties then null is returned`() { + val imageProperties = ImageProperties( + url = "https://example.com/image.png", + actionProperties = null + ) + + assertNull(imageProperties.actionProperties) + } + + @Test + fun `given multiple ImageProperties copies when comparing then each copy is distinct but equal`() { + val original = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.Crop, + contentDescription = "Test" + ) + + val copy1 = original.copy() + val copy2 = original.copy() + + assertEquals(copy1, copy2) + assertEquals(original, copy1) + assertEquals(original, copy2) + } + + @Test + fun `given ImageProperties when creating with empty url string then url is set to empty string`() { + val imageProperties = ImageProperties(url = "") + + assertEquals("", imageProperties.url) + } + + @Test + fun `given ImageProperties when creating with empty contentDescription string then contentDescription is set to empty string`() { + val imageProperties = ImageProperties(contentDescription = "") + + assertEquals("", imageProperties.contentDescription) + } + + @Test + fun `given ImageProperties with url when copying without url parameter then url remains unchanged`() { + val original = ImageProperties(url = "https://example.com/image.png") + val copied = original.copy(contentScale = CraftDContentScale.Crop) + + assertEquals(original.url, copied.url) + } + + @Test + fun `given two ImageProperties where one has all fields and other has all nulls when comparing then equals returns false`() { + val imageProperties1 = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.Crop, + contentDescription = "Test", + actionProperties = actionPropertiesMock + ) + val imageProperties2 = ImageProperties() + + assertNotEquals(imageProperties1, imageProperties2) + } + + @Test + fun `given ImageProperties when comparing with itself then equals returns true`() { + val imageProperties = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.Crop + ) + + assertEquals(imageProperties, imageProperties) + } + + @Test + fun `given ImageProperties when copying with same values then new instance is equal but distinct`() { + val original = ImageProperties( + url = "https://example.com/image.png", + contentScale = CraftDContentScale.Crop, + contentDescription = "Test", + actionProperties = actionPropertiesMock + ) + + val copied = original.copy( + url = original.url, + contentScale = original.contentScale, + contentDescription = original.contentDescription, + actionProperties = original.actionProperties + ) + + assertEquals(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..940cbb7 --- /dev/null +++ b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/domain/CraftDContentScaleTest.kt @@ -0,0 +1,261 @@ +package com.github.codandotv.craftd.androidcore.domain + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class CraftDContentScaleTest { + + @Test + fun `given CraftDContentScale enum when accessing CROP then returns CROP constant`() { + val scale = CraftDContentScale.CROP + assertEquals(CraftDContentScale.CROP, scale) + assertEquals("CROP", scale.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FIT then returns FIT constant`() { + val scale = CraftDContentScale.FIT + assertEquals(CraftDContentScale.FIT, scale) + assertEquals("FIT", scale.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FILL_BOUNDS then returns FILL_BOUNDS constant`() { + val scale = CraftDContentScale.FILL_BOUNDS + assertEquals(CraftDContentScale.FILL_BOUNDS, scale) + assertEquals("FILL_BOUNDS", scale.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FILL_WIDTH then returns FILL_WIDTH constant`() { + val scale = CraftDContentScale.FILL_WIDTH + assertEquals(CraftDContentScale.FILL_WIDTH, scale) + assertEquals("FILL_WIDTH", scale.name) + } + + @Test + fun `given CraftDContentScale enum when accessing FILL_HEIGHT then returns FILL_HEIGHT constant`() { + val scale = CraftDContentScale.FILL_HEIGHT + assertEquals(CraftDContentScale.FILL_HEIGHT, scale) + assertEquals("FILL_HEIGHT", scale.name) + } + + @Test + fun `given CraftDContentScale enum when accessing INSIDE then returns INSIDE constant`() { + val scale = CraftDContentScale.INSIDE + assertEquals(CraftDContentScale.INSIDE, scale) + assertEquals("INSIDE", scale.name) + } + + @Test + fun `given CraftDContentScale enum when accessing NONE then returns NONE constant`() { + val scale = CraftDContentScale.NONE + assertEquals(CraftDContentScale.NONE, scale) + assertEquals("NONE", scale.name) + } + + @Test + fun `given CraftDContentScale enum when getting all values then returns 7 constants`() { + val values = CraftDContentScale.values() + assertEquals(7, values.size) + } + + @Test + fun `given CraftDContentScale enum when iterating all values then contains all expected constants`() { + val values = CraftDContentScale.values().map { it.name } + assertEquals( + setOf("CROP", "FIT", "FILL_BOUNDS", "FILL_WIDTH", "FILL_HEIGHT", "INSIDE", "NONE"), + values.toSet() + ) + } + + @Test + fun `given string CROP when calling enumValueOf then returns CROP constant`() { + val scale = enumValueOf("CROP") + assertEquals(CraftDContentScale.CROP, scale) + } + + @Test + fun `given string FIT when calling enumValueOf then returns FIT constant`() { + val scale = enumValueOf("FIT") + assertEquals(CraftDContentScale.FIT, scale) + } + + @Test + fun `given string FILL_BOUNDS when calling enumValueOf then returns FILL_BOUNDS constant`() { + val scale = enumValueOf("FILL_BOUNDS") + assertEquals(CraftDContentScale.FILL_BOUNDS, scale) + } + + @Test + fun `given string FILL_WIDTH when calling enumValueOf then returns FILL_WIDTH constant`() { + val scale = enumValueOf("FILL_WIDTH") + assertEquals(CraftDContentScale.FILL_WIDTH, scale) + } + + @Test + fun `given string FILL_HEIGHT when calling enumValueOf then returns FILL_HEIGHT constant`() { + val scale = enumValueOf("FILL_HEIGHT") + assertEquals(CraftDContentScale.FILL_HEIGHT, scale) + } + + @Test + fun `given string INSIDE when calling enumValueOf then returns INSIDE constant`() { + val scale = enumValueOf("INSIDE") + assertEquals(CraftDContentScale.INSIDE, scale) + } + + @Test + fun `given string NONE when calling enumValueOf then returns NONE constant`() { + val scale = enumValueOf("NONE") + assertEquals(CraftDContentScale.NONE, scale) + } + + @Test + fun `given CraftDContentScale enum when comparing same instance then equals returns true`() { + val scale1 = CraftDContentScale.CROP + val scale2 = CraftDContentScale.CROP + assertEquals(scale1, scale2) + } + + @Test + fun `given CraftDContentScale enum when comparing different instances of same constant then equals returns true`() { + assertEquals(CraftDContentScale.FIT, CraftDContentScale.FIT) + } + + @Test + fun `given CraftDContentScale enum when comparing different constants then equals returns false`() { + val scale1 = CraftDContentScale.CROP + val scale2 = CraftDContentScale.FIT + assertNotNull(scale1) + assertNotNull(scale2) + } + + @Test + fun `given CraftDContentScale enum when getting hashCode of same constant then hashCode is consistent`() { + val scale1 = CraftDContentScale.CROP + val scale2 = CraftDContentScale.CROP + assertEquals(scale1.hashCode(), scale2.hashCode()) + } + + @Test + fun `given CraftDContentScale enum when getting ordinal of CROP then returns 0`() { + assertEquals(0, CraftDContentScale.CROP.ordinal) + } + + @Test + fun `given CraftDContentScale enum when getting ordinal of FIT then returns 1`() { + assertEquals(1, CraftDContentScale.FIT.ordinal) + } + + @Test + fun `given CraftDContentScale enum when getting ordinal of FILL_BOUNDS then returns 2`() { + assertEquals(2, CraftDContentScale.FILL_BOUNDS.ordinal) + } + + @Test + fun `given CraftDContentScale enum when getting ordinal of FILL_WIDTH then returns 3`() { + assertEquals(3, CraftDContentScale.FILL_WIDTH.ordinal) + } + + @Test + fun `given CraftDContentScale enum when getting ordinal of FILL_HEIGHT then returns 4`() { + assertEquals(4, CraftDContentScale.FILL_HEIGHT.ordinal) + } + + @Test + fun `given CraftDContentScale enum when getting ordinal of INSIDE then returns 5`() { + assertEquals(5, CraftDContentScale.INSIDE.ordinal) + } + + @Test + fun `given CraftDContentScale enum when getting ordinal of NONE then returns 6`() { + assertEquals(6, CraftDContentScale.NONE.ordinal) + } + + @Test + fun `given CraftDContentScale enum when calling toString on CROP then returns CROP`() { + val scale = CraftDContentScale.CROP + assertEquals("CROP", scale.toString()) + } + + @Test + fun `given CraftDContentScale enum when calling toString on FIT then returns FIT`() { + val scale = CraftDContentScale.FIT + assertEquals("FIT", scale.toString()) + } + + @Test + fun `given CraftDContentScale enum when verifying Stable annotation then annotation is present`() { + val annotation = CraftDContentScale::class.annotations.find { + it.annotationClass.simpleName == "Stable" + } + assertNotNull(annotation) + } + + @Test + fun `given CraftDContentScale enum when verifying Immutable annotation then annotation is present`() { + val annotation = CraftDContentScale::class.annotations.find { + it.annotationClass.simpleName == "Immutable" + } + assertNotNull(annotation) + } + + @Test + fun `given CraftDContentScale enum when creating list of all values then list is not empty`() { + val scaleList = listOf( + CraftDContentScale.CROP, + CraftDContentScale.FIT, + CraftDContentScale.FILL_BOUNDS, + CraftDContentScale.FILL_WIDTH, + CraftDContentScale.FILL_HEIGHT, + CraftDContentScale.INSIDE, + CraftDContentScale.NONE + ) + assertEquals(7, scaleList.size) + assertEquals(scaleList, CraftDContentScale.values().toList()) + } + + @Test + fun `given CraftDContentScale enum when filtering values by name containing FILL then returns 3 constants`() { + val filtered = CraftDContentScale.values().filter { it.name.contains("FILL") } + assertEquals(3, filtered.size) + } + + @Test + fun `given CraftDContentScale enum when checking first value then returns CROP`() { + val firstValue = CraftDContentScale.values().first() + assertEquals(CraftDContentScale.CROP, firstValue) + } + + @Test + fun `given CraftDContentScale enum when checking last value then returns NONE`() { + val lastValue = CraftDContentScale.values().last() + assertEquals(CraftDContentScale.NONE, lastValue) + } + + @Test + fun `given CraftDContentScale enum when using in set then no duplicates exist`() { + val scaleSet = setOf( + CraftDContentScale.CROP, + CraftDContentScale.CROP, + CraftDContentScale.FIT + ) + assertEquals(2, scaleSet.size) + } + + @Test + fun `given CraftDContentScale enum when using in map as key then retrievable by key`() { + val scaleMap = mapOf( + CraftDContentScale.CROP to "crop_scale", + CraftDContentScale.FIT to "fit_scale" + ) + assertEquals("crop_scale", scaleMap[CraftDContentScale.CROP]) + assertEquals("fit_scale", scaleMap[CraftDContentScale.FIT]) + } + +} \ 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..103df02 --- /dev/null +++ b/android_kmp/craftd-core/src/test/java/com/github/codandotv/craftd/androidcore/presentation/CraftDComponentKeyTest.kt @@ -0,0 +1,139 @@ +```kotlin +package com.github.codandotv.craftd.androidcore.presentation + +import io.mockk.junit4.MockKRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class CraftDComponentKeyTest { + + @get:Rule + val mockkRule = MockKRule(this) + + @Test + fun `given TEXT_VIEW_COMPONENT when accessed then key is CraftDTextView`() { + val component = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals("CraftDTextView", component.key) + } + + @Test + fun `given BUTTON_COMPONENT when accessed then key is CraftDButton`() { + val component = CraftDComponentKey.BUTTON_COMPONENT + assertEquals("CraftDButton", component.key) + } + + @Test + fun `given CHECK_BOX_COMPONENT when accessed then key is CraftDCheckBox`() { + val component = CraftDComponentKey.CHECK_BOX_COMPONENT + assertEquals("CraftDCheckBox", component.key) + } + + @Test + fun `given IMAGE_COMPONENT when accessed then key is CraftDImage`() { + val component = CraftDComponentKey.IMAGE_COMPONENT + assertEquals("CraftDImage", component.key) + } + + @Test + fun `given all enum constants when enumValueOf called then returns correct constant`() { + assertEquals(CraftDComponentKey.TEXT_VIEW_COMPONENT, enumValueOf("TEXT_VIEW_COMPONENT")) + assertEquals(CraftDComponentKey.BUTTON_COMPONENT, enumValueOf("BUTTON_COMPONENT")) + assertEquals(CraftDComponentKey.CHECK_BOX_COMPONENT, enumValueOf("CHECK_BOX_COMPONENT")) + assertEquals(CraftDComponentKey.IMAGE_COMPONENT, enumValueOf("IMAGE_COMPONENT")) + } + + @Test + fun `given CraftDComponentKey when values called then returns all four constants`() { + val values = CraftDComponentKey.values() + assertEquals(4, values.size) + assertEquals(CraftDComponentKey.TEXT_VIEW_COMPONENT, values[0]) + assertEquals(CraftDComponentKey.BUTTON_COMPONENT, values[1]) + assertEquals(CraftDComponentKey.CHECK_BOX_COMPONENT, values[2]) + assertEquals(CraftDComponentKey.IMAGE_COMPONENT, values[3]) + } + + @Test + fun `given TEXT_VIEW_COMPONENT when name called then returns TEXT_VIEW_COMPONENT`() { + val component = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals("TEXT_VIEW_COMPONENT", component.name) + } + + @Test + fun `given BUTTON_COMPONENT when ordinal called then returns 1`() { + val component = CraftDComponentKey.BUTTON_COMPONENT + assertEquals(1, component.ordinal) + } + + @Test + fun `given CHECK_BOX_COMPONENT when ordinal called then returns 2`() { + val component = CraftDComponentKey.CHECK_BOX_COMPONENT + assertEquals(2, component.ordinal) + } + + @Test + fun `given IMAGE_COMPONENT when ordinal called then returns 3`() { + val component = CraftDComponentKey.IMAGE_COMPONENT + assertEquals(3, component.ordinal) + } + + @Test + fun `given same CraftDComponentKey when compared then equals returns true`() { + val component1 = CraftDComponentKey.TEXT_VIEW_COMPONENT + val component2 = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals(component1, component2) + } + + @Test + fun `given different CraftDComponentKey when compared then equals returns false`() { + val component1 = CraftDComponentKey.TEXT_VIEW_COMPONENT + val component2 = CraftDComponentKey.BUTTON_COMPONENT + assertNotNull(component1) + assertNotNull(component2) + } + + @Test + fun `given same CraftDComponentKey when hashCode called then returns same hash`() { + val component1 = CraftDComponentKey.TEXT_VIEW_COMPONENT + val component2 = CraftDComponentKey.TEXT_VIEW_COMPONENT + assertEquals(component1.hashCode(), component2.hashCode()) + } + + @Test + fun `given TEXT_VIEW_COMPONENT when toString called then returns enum representation`() { + val component = CraftDComponentKey.TEXT_VIEW_COMPONENT + val stringRepresentation = component.toString() + assertNotNull(stringRepresentation) + } + + @Test + fun `given CRAFT_D constant when accessed then value is CraftD`() { + assertEquals("CraftD", CRAFT_D) + } + + @Test + fun `given all components when keys accessed then all keys contain CraftD prefix`() { + CraftDComponentKey.values().forEach { component -> + assert(component.key.startsWith(CRAFT_D)) + } + } + + @Test + fun `given TEXT_VIEW_COMPONENT when compared by key then TEXT_VIEW_COMPONENT has correct key`() { + val expectedKey = "CraftDTextView" + val actualKey = CraftDComponentKey.TEXT_VIEW_COMPONENT.key + assertEquals(expectedKey, actualKey) + } + + @Test + fun `given all enum constants when checked then none have empty key`() { + CraftDComponentKey.values().forEach { component -> + assert(component.key.isNotEmpty()) + } + } +} +``` \ 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/.openspec.yaml b/openspec/changes/add-craftd-image-android-kmp/.openspec.yaml similarity index 100% rename from openspec/changes/add-craftd-image/.openspec.yaml rename to openspec/changes/add-craftd-image-android-kmp/.openspec.yaml diff --git a/openspec/changes/add-craftd-image-android-kmp/design.md b/openspec/changes/add-craftd-image-android-kmp/design.md new file mode 100644 index 0000000..87f16c3 --- /dev/null +++ b/openspec/changes/add-craftd-image-android-kmp/design.md @@ -0,0 +1,54 @@ +## Context + +CraftD has `CraftDButton`, `CraftDText`, and `CraftDCheckBox` as built-in components. There is no image component. PR #78 attempted this but was rejected because it coupled Coil directly inside the builder, violating architectural rule 10 (external dependencies must be abstracted). This design delivers `CraftDImage` following the correct pattern. + +The component must work on three targets: Android Compose, KMP (commonMain), and Android View System (XML). iOS and Flutter are out of scope for this change. + +## Goals / Non-Goals + +**Goals:** +- Add `ImageProperties` model to `craftd-core/commonMain` +- Add `CraftDImage` composable and `CraftDImageBuilder` to `craftd-compose/commonMain` +- Add `CraftDImageComponentRender` to `craftd-xml` +- Register both builders in their respective `CraftDBuilderManager` +- Add `IMAGE_COMPONENT` entry to `CraftDComponentKey` +- Keep image loading completely decoupled from the library via an injectable `imageLoader` lambda + +**Non-Goals:** +- iOS / Flutter implementations (separate change) +- Bundling Coil or any image lib as a transitive dependency of craftd-compose or craftd-xml +- Caching, placeholder, or error-image strategies inside the lib (delegated to the injected loader) + +## Decisions + +### 1. Injectable imageLoader over a built-in loader + +`CraftDImageBuilder` receives an `imageLoader: @Composable (url: String, contentDescription: String?, modifier: Modifier) -> Unit` parameter in its constructor. The consumer provides the Coil (or any other) implementation. + +**Why:** Rule 10 forbids coupling third-party libs inside builders. The consumer already manages dependencies; injecting via constructor is the established escape hatch. + +**Alternative considered:** Ship a default Coil implementation inside craftd-compose as an optional artifact. Rejected because it would introduce a transitive dependency and complicate version management. + +### 2. ContentScale represented as a domain enum `CraftDContentScale` + +A new enum `CraftDContentScale` lives in `craftd-core/commonMain/domain/` mirroring `CraftDAlign`. The builder maps it to `androidx.compose.ui.layout.ContentScale` via an `internal` extension function `toContentScale()`. + +**Why:** `ContentScale` is a Compose type — it cannot live in `commonMain` of `craftd-core` (which must stay platform-free). The enum in core is the platform-neutral representation; the mapping lives in the compose module where it belongs. + +**Alternative considered:** Pass a raw string and parse it in the builder. Rejected — no compile-time safety, harder to test. + +### 3. XML implementation mirrors Compose: injectable loader as a View lambda + +`CraftDImageComponentRender` receives `imageLoader: (url: String, imageView: ImageView) -> Unit` in its constructor. The consumer binds Coil (or Glide/Picasso) at the call site. + +**Why:** Same rationale as decision 1, adapted to the View System API. + +### 4. `IMAGE_COMPONENT` key follows existing naming convention + +Key string: `"CraftDImage"` (consistent with `CRAFT_D` constant + component name pattern in `CraftDComponentKey`). + +## Risks / Trade-offs + +- **Consumer boilerplate**: every app must wire the `imageLoader` lambda. Mitigated by providing a clear usage example in `docs/how-to-use/compose.md` and `view-system.md`. +- **ContentScale mapping coverage**: if a new `ContentScale` variant is added by Compose later, the enum needs updating. Low risk — the set is stable. +- **XML ImageView constraints**: the XML component uses a standard `ImageView`; advanced features (rounded corners, cross-fade) remain the loader's responsibility. Acceptable — it matches the abstraction boundary. diff --git a/openspec/changes/add-craftd-image-android-kmp/proposal.md b/openspec/changes/add-craftd-image-android-kmp/proposal.md new file mode 100644 index 0000000..eb08b84 --- /dev/null +++ b/openspec/changes/add-craftd-image-android-kmp/proposal.md @@ -0,0 +1,30 @@ +## Why + +CraftD currently supports text and button components but has no image component, leaving apps unable to render image elements via Server Driven UI. Adding `CraftDImage` closes this gap and enables servers to deliver image-based layouts across all platforms. + +## What Changes + +- New `ImageProperties` data class in `craftd-core/commonMain` (url, contentScale, contentDescription) +- New `CraftDImage` composable in `craftd-compose/commonMain` +- New `CraftDImageBuilder` (Compose/KMP) registered in `CraftDBuilderManager` +- New `CraftDImageBuilder` (XML) with `ImageComponentRender` registered in the XML `CraftDBuilderManager` +- `CraftDComponentKey.IMAGE_COMPONENT` enum entry added +- `imageLoader` abstraction parameter on the builder — Coil (or any loader) injected by the consumer, not coupled inside the lib +- Unit tests for `ImageProperties` and `toContentScale()` extension + +## Capabilities + +### New Capabilities + +- `craftd-image`: Renders image components via SDUI on Android Compose, KMP, and XML platforms, with an injectable image loader abstraction. + +### Modified Capabilities + + + +## Impact + +- `craftd-core`: new model class `ImageProperties`, new enum entry in `CraftDComponentKey` +- `craftd-compose`: new composable + builder, registration in `CraftDBuilderManager` +- `craftd-xml`: new render + registration in XML `CraftDBuilderManager` +- External dependency: Coil 3 (multiplatform) is the reference loader but must be injected by the consumer — no new lib dep added to core/compose modules diff --git a/openspec/changes/add-craftd-image-android-kmp/specs/craftd-image/spec.md b/openspec/changes/add-craftd-image-android-kmp/specs/craftd-image/spec.md new file mode 100644 index 0000000..cdb351c --- /dev/null +++ b/openspec/changes/add-craftd-image-android-kmp/specs/craftd-image/spec.md @@ -0,0 +1,66 @@ +## ADDED Requirements + +### Requirement: ImageProperties model exists in craftd-core +The system SHALL provide a `@Serializable` data class `ImageProperties` in `craftd-core/commonMain` containing at minimum: `url: String?`, `contentScale: CraftDContentScale?`, `contentDescription: String?`, and `actionProperties: ActionProperties?`. + +#### Scenario: Server payload is deserialized into ImageProperties +- **WHEN** the SDUI JSON contains an image component value with `url`, `contentScale`, and `contentDescription` fields +- **THEN** `ImageProperties` is correctly deserialized with all fields populated + +#### Scenario: Optional fields are absent from payload +- **WHEN** the SDUI JSON image value omits `contentScale` or `contentDescription` +- **THEN** the missing fields default to `null` and no exception is thrown + +### Requirement: CraftDContentScale enum covers standard scale modes +The system SHALL provide a `CraftDContentScale` enum in `craftd-core/commonMain/domain/` with values: `CROP`, `FIT`, `FILL_BOUNDS`, `FILL_WIDTH`, `FILL_HEIGHT`, `INSIDE`, `NONE`. + +#### Scenario: All enum values map to a ContentScale in Compose +- **WHEN** `toContentScale()` is called on each `CraftDContentScale` value +- **THEN** each value returns the corresponding `androidx.compose.ui.layout.ContentScale` + +### Requirement: IMAGE_COMPONENT key registered in CraftDComponentKey +The system SHALL include `IMAGE_COMPONENT("CraftDImage")` as an entry in the `CraftDComponentKey` enum in `craftd-core`. + +#### Scenario: Key is accessible from compose and xml modules +- **WHEN** `CraftDComponentKey.IMAGE_COMPONENT.key` is referenced from `craftd-compose` or `craftd-xml` +- **THEN** it returns the string `"CraftDImage"` + +### Requirement: CraftDImageBuilder renders image via injected loader (Compose) +The system SHALL provide `CraftDImageBuilder` in `craftd-compose/commonMain` that accepts an `imageLoader` composable lambda and delegates image rendering to it. The builder SHALL handle a null `ImageProperties` gracefully (no-op / empty render). + +#### Scenario: Builder delegates rendering to injected imageLoader +- **WHEN** `CraftDImageBuilder(imageLoader = myLoader).craft(model, listener)` is called with a valid `ImageProperties` +- **THEN** `myLoader` is invoked with the `url`, `contentDescription`, and a `Modifier` + +#### Scenario: Null ImageProperties results in no-op +- **WHEN** the JSON value cannot be deserialized into `ImageProperties` +- **THEN** the builder renders nothing and does not throw + +#### Scenario: ActionProperties triggers listener on click +- **WHEN** `ImageProperties.actionProperties` is non-null and the user taps the image +- **THEN** `CraftDViewListener.invoke(actionProperties)` is called + +### Requirement: CraftDImageBuilder registered in Compose CraftDBuilderManager +The system SHALL register `CraftDComponentKey.IMAGE_COMPONENT.key to CraftDImageBuilder(imageLoader)` in `CraftDBuilderManager` (craftd-compose). The registration SHALL require the consumer to pass the `imageLoader` at setup time. + +#### Scenario: CraftDBuilderManager resolves image builder by key +- **WHEN** `getBuilder("CraftDImage")` is called on a configured `CraftDBuilderManager` +- **THEN** it returns the registered `CraftDImageBuilder` instance + +### Requirement: CraftDImageComponentRender renders image via injected loader (XML) +The system SHALL provide `CraftDImageComponentRender` in `craftd-xml` that accepts an `imageLoader: (url: String, imageView: ImageView) -> Unit` and delegates loading to it. + +#### Scenario: Render delegates loading to injected loader +- **WHEN** `bindView` is called with a valid `ImageProperties` +- **THEN** the injected `imageLoader` is called with the `url` and the `ImageView` + +#### Scenario: ActionProperties triggers listener on click in XML +- **WHEN** `ImageProperties.actionProperties` is non-null and the user taps the image view +- **THEN** `onClickListener.invoke(actionProperties)` is called + +### Requirement: CraftDImageComponentRender registered in XML CraftDBuilderManager +The system SHALL add `CraftDImageComponentRender(imageLoader, onAction)` to the render list returned by `getBuilderRenders()` in `craftd-xml`'s `CraftDBuilderManager`. + +#### Scenario: XML BuilderManager resolves image render by key +- **WHEN** `getBuilderRenders()` is called and the simpleProperties list contains a key `"CraftDImage"` +- **THEN** the returned list includes a `CraftDImageComponentRender` bound to that key 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