From c6d70569be4565f249af7b400aa4c13ae5ccee43 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 10:50:18 -0400 Subject: [PATCH 01/12] feat: add tintCap support for Icon and Image components Adds a TintCap sealed class (All / Undefined / Index / Range / Layers) that controls which top-level layers of an ImageVector receive the tint color. The Icon and Image composables now accept a tintCap parameter; layers not matched by the cap keep their original colors. - TintCap.All tints every layer (default for Icon) - TintCap.Undefined skips tinting entirely (default for Image) - TintCap.index(n), TintCap.range(r), TintCap.layers(...) for selective tinting of one, a range, or a set of layers Includes the Icons.MapTruck fixture with 4 distinct top-level layers and unit + instrumented UI tests covering every TintCap variant. --- README.md | 129 +++++++++++++++- component/build.gradle.kts | 4 + .../component/image/IconTintCapTest.kt | 134 +++++++++++++++++ .../component/image/ImageTintCapTest.kt | 110 ++++++++++++++ .../com/blipblipcode/component/image/Icon.kt | 46 ++++++ .../com/blipblipcode/component/image/Image.kt | 64 ++++++++ .../component/image/ImageVectorTinter.kt | 116 ++++++++++++++ .../blipblipcode/component/image/MapTruck.kt | 141 ++++++++++++++++++ .../blipblipcode/component/image/TintCap.kt | 84 +++++++++++ .../component/image/ImageVectorTinterTest.kt | 132 ++++++++++++++++ .../component/image/TintCapTest.kt | 92 ++++++++++++ 11 files changed, 1049 insertions(+), 3 deletions(-) create mode 100644 component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt create mode 100644 component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt create mode 100644 component/src/main/java/com/blipblipcode/component/image/Icon.kt create mode 100644 component/src/main/java/com/blipblipcode/component/image/Image.kt create mode 100644 component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt create mode 100644 component/src/main/java/com/blipblipcode/component/image/MapTruck.kt create mode 100644 component/src/main/java/com/blipblipcode/component/image/TintCap.kt create mode 100644 component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt create mode 100644 component/src/test/java/com/blipblipcode/component/image/TintCapTest.kt diff --git a/README.md b/README.md index 4fdf0bb..f836a6a 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,124 @@ RangeSliderComponent( --- +### 4. Icon (con `tintCap`) + +Wrapper sobre `androidx.compose.material3.Icon` que añade el parámetro `tintCap` para controlar qué capas (layers) de un `ImageVector` reciben el color de `tint`. Las capas no afectadas conservan sus colores originales. + +**Propiedades personalizables:** +| Propiedad | Tipo | Descripción | +|-----------|------|-------------| +| `imageVector` | `ImageVector` | Vector a renderizar | +| `contentDescription` | `String?` | Descripción para accesibilidad | +| `modifier` | `Modifier` | Modificador estándar | +| `tint` | `Color` | Color a aplicar (por defecto `LocalContentColor.current`) | +| `tintCap` | `TintCap` | Alcance del tint (ver tabla abajo, por defecto `TintCap.All`) | + +**Variantes de `TintCap`:** +| Variante | Descripción | +|----------|-------------| +| `TintCap.All` | Pinta **todas** las capas con `tint` (default para `Icon`, equivale al comportamiento estándar de Compose) | +| `TintCap.Undefined` | **No aplica** ninguna transformación; el vector se renderiza con sus colores originales | +| `TintCap.index(n)` | Pinta **solo** la capa top-level en el índice `n` | +| `TintCap.range(rango)` | Pinta **todas** las capas cuyo índice esté dentro de `rango` (ej: `0..2`) | +| `TintCap.layers(1, 3)` | Pinta **solo** las capas top-level en los índices indicados | + +> Una "capa" es cada nodo de primer nivel del `ImageVector` raíz (ya sea un `VectorGroup` o un `VectorPath` directo). Si la capa es un grupo, todo su contenido se pinta con el mismo criterio. + +**Ejemplos de uso:** + +```kotlin +// Default: pinta todas las capas +Icon( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red +) + +// Pinta solo la capa top-level en el índice 1 +Icon( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, + tintCap = TintCap.index(1) +) + +// Pinta el rango 0..2 y respeta el resto +Icon( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, + tintCap = TintCap.range(0..2) +) + +// Pinta múltiples capas específicas +Icon( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, + tintCap = TintCap.layers(1, 3) +) + +// Respeta los colores originales del vector ignorando tint +Icon( + imageVector = Icons.Filled.Favorite, + contentDescription = null, + tint = Color.Red, // se ignora por estar Undefined + tintCap = TintCap.Undefined +) +``` + +--- + +### 5. Image (con `tintCap`) + +Wrapper sobre `androidx.compose.foundation.Image` con la misma potencia de `tintCap` que `Icon`. Pensado para vectores con varias capas donde queremos preservar colores originales (logos, ilustraciones, etc.). + +**Propiedades personalizables:** +| Propiedad | Tipo | Descripción | +|-----------|------|-------------| +| `imageVector` | `ImageVector` | Vector a renderizar | +| `contentDescription` | `String?` | Descripción para accesibilidad | +| `modifier` | `Modifier` | Modificador estándar | +| `alignment` | `Alignment` | Alineación dentro del espacio disponible | +| `contentScale` | `ContentScale` | Estrategia de escalado (default `ContentScale.Fit`) | +| `alpha` | `Float` | Opacidad (default `DefaultAlpha`) | +| `colorFilter` | `ColorFilter?` | Filtro de color opcional adicional | +| `tint` | `Color?` | Color a aplicar (opcional) | +| `tintCap` | `TintCap` | Alcance del tint (default `TintCap.Undefined`) | + +**Ejemplo de uso:** + +```kotlin +// Logo con fondo original y un solo trazo tintado +Image( + imageVector = myBrandLogo, + contentDescription = "Logo", + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.primary, + tintCap = TintCap.index(0) +) + +// Todas las capas pintadas con tint +Image( + imageVector = myBrandLogo, + contentDescription = "Logo", + modifier = Modifier.size(120.dp), + tint = MaterialTheme.colorScheme.primary, + tintCap = TintCap.All +) + +// Colores originales del vector intactos (sin transformación) +Image( + imageVector = myBrandLogo, + contentDescription = "Logo", + modifier = Modifier.size(120.dp), + tintCap = TintCap.Undefined +) +``` + +--- + ## 🎨 Sistema de Colores Todos los componentes utilizan `SliderColorsDefaults` para una gestión coherente de colores: @@ -161,9 +279,14 @@ composecomponents/ │ │ └── SliderSizeDefaults.kt │ ├── linear/ # LinearProgressIndicatorComponents │ │ └── LinearProgressIndicatorComponents.kt -│ └── range/ # RangeSliderComponent -│ ├── RangeSliderComponent.kt -│ └── RangeSliderDefaults.kt +│ ├── range/ # RangeSliderComponent +│ │ ├── RangeSliderComponent.kt +│ │ └── RangeSliderDefaults.kt +│ └── image/ # Icon e Image con tintCap +│ ├── TintCap.kt +│ ├── ImageVectorTinter.kt +│ ├── Icon.kt +│ └── Image.kt └── gradle/ └── libs.versions.toml # Catálogo de versiones ``` diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 254342a..40a0187 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -78,4 +78,8 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.compose.ui.test.manifest) + debugImplementation(libs.androidx.compose.ui.test.manifest) } \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt new file mode 100644 index 0000000..3f5ccfb --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt @@ -0,0 +1,134 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [Icon] with the various [TintCap] variants, using the + * [Icons.MapTruck] fixture which contains 4 distinct top-level layers: + * 0 → `wheels` (group), 1 → `body`, 2 → `cab`, 3 → `cargo`. + * + * Each test renders the truck inside an [Icon] with a specific [TintCap], captures the + * resulting bitmap, and samples well-known pixel coordinates to verify that only the layers + * targeted by [tintCap] receive the tint colour while the rest keep their original colour. + */ +class IconTintCapTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "icon-under-test" + + // Render size: square so the 64x64 viewport maps cleanly to a square pixel buffer. + private val iconSizeDp = 128.dp + + private val tintColor = Color(0xFFFFEB3B) // yellow + + // Default layer colours of Icons.MapTruck — see MapTruck.kt + private val wheelsColor = Color(0xFF424242) + private val bodyColor = Color(0xFFE53935) + private val cabColor = Color(0xFF1E88E5) + private val cargoColor = Color(0xFF43A047) + + /** + * Samples the rendered icon at the centre of every layer. Returns an `IntArray` of + * length 4 ordered as: [wheels, body, cab, cargo]. Coordinates are expressed in the + * rendered pixel buffer; with [iconSizeDp] = 128.dp and a 64x64 viewport, the scale is + * exactly 2 px per unit so positions match the source coords * 2. + */ + private fun renderAndSample(cap: TintCap): IntArray { + composeTestRule.setContent { + Surface(modifier = Modifier.background(Color.White)) { + Box( + modifier = Modifier.size(iconSizeDp).background(Color.White) + ) { + Icon( + imageVector = Icons.MapTruck, + contentDescription = null, + modifier = Modifier.size(iconSizeDp).testTag(testTagValue), + tint = tintColor, + tintCap = cap + ) + } + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 14 / 64, h * 50 / 64), // front tire (wheels group) + bmp.getPixel(w * 22 / 64, h * 48 / 64), // body + bmp.getPixel(w * 50 / 64, h * 38 / 64), // cab + bmp.getPixel(w * 22 / 64, h * 30 / 64) // cargo + ) + } + + @Test + fun undefined_preserves_every_layer_original_color() { + val px = renderAndSample(TintCap.Undefined) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun all_paints_every_layer_with_tint() { + val px = renderAndSample(TintCap.All) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } + + @Test + fun index_tints_only_the_target_layer() { + val px = renderAndSample(TintCap.index(2)) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun range_tints_only_layers_inside_the_range() { + val px = renderAndSample(TintCap.range(0..1)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun layers_tints_only_the_specified_positions() { + val px = renderAndSample(TintCap.layers(0, 3)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } + + @Test + fun out_of_range_index_preserves_original_colors() { + val px = renderAndSample(TintCap.index(99)) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } +} \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt new file mode 100644 index 0000000..7789064 --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt @@ -0,0 +1,110 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [Image] with the various [TintCap] variants, using the + * [Icons.MapTruck] fixture. Mirrors [IconTintCapTest] for the Image composable. + * + * Top-level layers: 0 → wheels (group), 1 → body, 2 → cab, 3 → cargo. + */ +class ImageTintCapTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "image-under-test" + private val imageSizeDp = 128.dp + + private val tintColor = Color(0xFFFFEB3B) // yellow + + private val wheelsColor = Color(0xFF424242) + private val bodyColor = Color(0xFFE53935) + private val cabColor = Color(0xFF1E88E5) + private val cargoColor = Color(0xFF43A047) + + private fun renderAndSample(cap: TintCap): IntArray { + composeTestRule.setContent { + Box( + modifier = Modifier.size(imageSizeDp).background(Color.White) + ) { + Image( + imageVector = Icons.MapTruck, + contentDescription = null, + modifier = Modifier.size(imageSizeDp).testTag(testTagValue), + tint = tintColor, + tintCap = cap + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 14 / 64, h * 50 / 64), + bmp.getPixel(w * 22 / 64, h * 48 / 64), + bmp.getPixel(w * 50 / 64, h * 38 / 64), + bmp.getPixel(w * 22 / 64, h * 30 / 64) + ) + } + + @Test + fun undefined_with_tint_still_preserves_original_colors() { + val px = renderAndSample(TintCap.Undefined) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun all_paints_every_layer_with_tint() { + val px = renderAndSample(TintCap.All) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } + + @Test + fun index_tints_only_the_target_layer() { + val px = renderAndSample(TintCap.index(2)) + assertEquals(wheelsColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(tintColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun range_tints_only_layers_inside_the_range() { + val px = renderAndSample(TintCap.range(0..1)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(tintColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(cargoColor.toArgb(), px[3]) + } + + @Test + fun layers_tints_only_the_specified_positions() { + val px = renderAndSample(TintCap.layers(0, 3)) + assertEquals(tintColor.toArgb(), px[0]) + assertEquals(bodyColor.toArgb(), px[1]) + assertEquals(cabColor.toArgb(), px[2]) + assertEquals(tintColor.toArgb(), px[3]) + } +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/Icon.kt b/component/src/main/java/com/blipblipcode/component/image/Icon.kt new file mode 100644 index 0000000..501a679 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/Icon.kt @@ -0,0 +1,46 @@ +package com.blipblipcode.component.image + +import androidx.compose.material3.Icon as MaterialIcon +import androidx.compose.material3.LocalContentColor +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector + +/** + * A thin wrapper around Material 3's [MaterialIcon] that adds [tintCap] support for vector + * drawables. [tintCap] controls which layers of the [imageVector] receive the [tint] color; + * the rest are rendered with their original colors. + * + * @see TintCap + */ +@Composable +fun Icon( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + tint: Color = LocalContentColor.current, + tintCap: TintCap = TintCap.All, +) { + val recolored: ImageVector? = remember(imageVector, tint, tintCap) { + when { + tintCap.isUndefined -> null + tintCap === TintCap.All -> null + else -> recolorImageVector(imageVector, tint, tintCap) + } + } + val effectiveTint: Color = when { + tintCap.isUndefined -> Color.Unspecified + recolored != null -> Color.Unspecified + else -> tint + } + val effectiveVector: ImageVector = recolored ?: imageVector + + MaterialIcon( + imageVector = effectiveVector, + contentDescription = contentDescription, + modifier = modifier, + tint = effectiveTint + ) +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/Image.kt b/component/src/main/java/com/blipblipcode/component/image/Image.kt new file mode 100644 index 0000000..4091ee4 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/Image.kt @@ -0,0 +1,64 @@ +package com.blipblipcode.component.image + +import androidx.compose.foundation.Image as FoundationImage +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.DefaultAlpha + +/** + * A wrapper around Compose Foundation's [FoundationImage] that adds [tintCap] support for + * vector drawables. [tintCap] controls which layers of the [imageVector] receive the [tint] + * color; the rest are rendered with their original colors. + * + * - When [tint] is `null` no tint is applied (standard behavior). + * - When [tint] is non-null and [tintCap] is [TintCap.Undefined], the tint is ignored and the + * vector's original colors are preserved. + * - When [tint] is non-null and [tintCap] is [TintCap.All], the tint is applied to every + * layer using [ColorFilter.tint]. + * - When [tint] is non-null and [tintCap] is [TintCap.Index], [TintCap.Range] or + * [TintCap.Layers], the vector is rebuilt so only the matching layers are tinted and + * [ColorFilter] is left untouched. + */ +@Composable +fun Image( + imageVector: ImageVector, + contentDescription: String?, + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + alpha: Float = DefaultAlpha, + colorFilter: ColorFilter? = null, + tint: Color? = null, + tintCap: TintCap = TintCap.Undefined, +) { + val recolored: ImageVector? = remember(imageVector, tint, tintCap) { + if (tint == null || tintCap.isUndefined || tintCap === TintCap.All) { + null + } else { + recolorImageVector(imageVector, tint, tintCap) + } + } + val effectiveColorFilter: ColorFilter? = when { + tint == null -> colorFilter + tintCap.isUndefined -> colorFilter + tintCap === TintCap.All -> colorFilter ?: ColorFilter.tint(tint) + else -> colorFilter + } + val effectiveVector: ImageVector = recolored ?: imageVector + + FoundationImage( + imageVector = effectiveVector, + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + alpha = alpha, + colorFilter = effectiveColorFilter + ) +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt b/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt new file mode 100644 index 0000000..76c8604 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/ImageVectorTinter.kt @@ -0,0 +1,116 @@ +package com.blipblipcode.component.image + +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorGroup +import androidx.compose.ui.graphics.vector.VectorNode +import androidx.compose.ui.graphics.vector.VectorPath +import androidx.compose.ui.graphics.vector.group + +/** + * Rebuilds [source] into a new [ImageVector] applying [tint] only to the layers matched by + * [tintCap]. Layers that do not match keep their original colors. + * + * When [tintCap] is [TintCap.All] (the default for [Icon]) the source vector is returned + * untouched and tinting is expected to be applied externally via the standard + * `tint` parameter — this avoids rebuilding the vector when not needed. + * + * When [tintCap] is [TintCap.Undefined] (the default for [Image]) the source vector is + * returned untouched and no tint is applied at any level. + */ +internal fun recolorImageVector( + source: ImageVector, + tint: Color, + tintCap: TintCap +): ImageVector { + if (tintCap.isUndefined) return source + + val builder = ImageVector.Builder( + name = source.name, + defaultWidth = source.defaultWidth, + defaultHeight = source.defaultHeight, + viewportWidth = source.viewportWidth, + viewportHeight = source.viewportHeight + ) + + val tintBrush: Brush = SolidColor(tint) + + // Top-level nodes form the layer-index space. Iterate as a snapshot to be safe. + val topLevel = source.root.toNodeList() + topLevel.forEachIndexed { index, node -> + val shouldTint = tintCap.appliesTo(index) + copyNode(builder, node, tintBrush, shouldTint) + } + + return builder.build() +} + +private fun copyNode( + builder: ImageVector.Builder, + node: VectorNode, + tintBrush: Brush, + shouldTint: Boolean +) { + when (node) { + is VectorGroup -> copyGroupInto(builder, node, tintBrush, shouldTint) + is VectorPath -> copyPathInto(builder, node, tintBrush, shouldTint) + } +} + +private fun copyGroupInto( + builder: ImageVector.Builder, + sourceGroup: VectorGroup, + tintBrush: Brush, + shouldTint: Boolean +) { + builder.group( + name = sourceGroup.name, + rotate = sourceGroup.rotation, + pivotX = sourceGroup.pivotX, + pivotY = sourceGroup.pivotY, + scaleX = sourceGroup.scaleX, + scaleY = sourceGroup.scaleY, + translationX = sourceGroup.translationX, + translationY = sourceGroup.translationY, + clipPathData = sourceGroup.clipPathData + ) { + val children = sourceGroup.toNodeList() + children.forEach { child -> + copyNode(this, child, tintBrush, shouldTint) + } + } +} + +private fun copyPathInto( + builder: ImageVector.Builder, + sourcePath: VectorPath, + tintBrush: Brush, + shouldTint: Boolean +) { + builder.addPath( + pathData = sourcePath.pathData, + pathFillType = sourcePath.pathFillType, + name = sourcePath.name, + fill = if (shouldTint) tintBrush else sourcePath.fill, + fillAlpha = sourcePath.fillAlpha, + stroke = if (shouldTint) tintBrush else sourcePath.stroke, + strokeAlpha = sourcePath.strokeAlpha, + strokeLineWidth = sourcePath.strokeLineWidth, + strokeLineCap = sourcePath.strokeLineCap, + strokeLineJoin = sourcePath.strokeLineJoin, + strokeLineMiter = sourcePath.strokeLineMiter, + trimPathStart = sourcePath.trimPathStart, + trimPathEnd = sourcePath.trimPathEnd, + trimPathOffset = sourcePath.trimPathOffset + ) +} + +/** Snapshot helper for any [VectorGroup] iterable. */ +private fun VectorGroup.toNodeList(): List { + val out = ArrayList(size) + val it = iterator() + while (it.hasNext()) out += it.next() + return out +} \ No newline at end of file diff --git a/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt new file mode 100644 index 0000000..02c66b9 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt @@ -0,0 +1,141 @@ +package com.blipblipcode.component.image + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathNode +import androidx.compose.ui.graphics.vector.group +import androidx.compose.ui.unit.dp + +/** + * A multi-layer truck icon used as a UI-test fixture for [Icon] / [Image] with [TintCap]. + * + * Top-level layers (indices): + * 0 → `wheels` (group containing both tires) + * 1 → `body` (the truck bed / chassis) + * 2 → `cab` (the driver cabin + window) + * 3 → `cargo` (the cargo box on the bed) + * + * Each layer uses a distinctive default colour so it is easy to verify which layers get + * tinted by each [TintCap] variant. + */ +val Icons.MapTruck: ImageVector + get() = _MapTruck ?: ImageVector.Builder( + name = "MapTruck", + defaultWidth = 64.dp, + defaultHeight = 64.dp, + viewportWidth = 64f, + viewportHeight = 64f + ).apply { + // Layer 0: wheels group (both tires inside one top-level group) + group( + name = "wheels", + rotate = 0f, + pivotX = 0f, + pivotY = 0f, + scaleX = 1f, + scaleY = 1f, + translationX = 0f, + translationY = 0f, + clipPathData = emptyList() + ) { + // Front tire (left side, bottom) + addPath( + pathData = tirePath(cx = 14f, cy = 50f, r = 6f), + name = "tire-front", + fill = SolidColor(Color(0xFF424242)), + fillAlpha = 1f, + stroke = SolidColor(Color(0xFF212121)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f + ) + // Rear tire (right side, bottom) + addPath( + pathData = tirePath(cx = 50f, cy = 50f, r = 6f), + name = "tire-rear", + fill = SolidColor(Color(0xFF424242)), + fillAlpha = 1f, + stroke = SolidColor(Color(0xFF212121)), + strokeAlpha = 1f, + strokeLineWidth = 1.5f + ) + } + + // Layer 1: truck bed / chassis + addPath( + pathData = listOf( + PathNode.MoveTo(4f, 44f), + PathNode.LineTo(40f, 44f), + PathNode.LineTo(40f, 52f), + PathNode.LineTo(4f, 52f), + PathNode.Close + ), + name = "body", + fill = SolidColor(Color(0xFFE53935)), + fillAlpha = 1f + ) + + // Layer 2: driver cabin + addPath( + pathData = listOf( + PathNode.MoveTo(40f, 24f), + PathNode.LineTo(60f, 24f), + PathNode.LineTo(60f, 52f), + PathNode.LineTo(40f, 52f), + PathNode.Close + ), + name = "cab", + fill = SolidColor(Color(0xFF1E88E5)), + fillAlpha = 1f + ) + addPath( + pathData = listOf( + PathNode.MoveTo(44f, 28f), + PathNode.LineTo(56f, 28f), + PathNode.LineTo(56f, 38f), + PathNode.LineTo(44f, 38f), + PathNode.Close + ), + name = "window", + fill = SolidColor(Color(0xFFBBDEFB)), + fillAlpha = 1f + ) + + // Layer 3: cargo box + addPath( + pathData = listOf( + PathNode.MoveTo(6f, 18f), + PathNode.LineTo(38f, 18f), + PathNode.LineTo(38f, 42f), + PathNode.LineTo(6f, 42f), + PathNode.Close + ), + name = "cargo", + fill = SolidColor(Color(0xFF43A047)), + fillAlpha = 1f + ) + }.build().also { _MapTruck = it } + +private var _MapTruck: ImageVector? = null + +/** + * Approximation of a circle centred at (cx, cy) with radius [r] using cubic bezier curves. + * Sufficient for testing tint behaviour on filled regions. + */ +private fun tirePath(cx: Float, cy: Float, r: Float): List { + val k = 0.5522847498f * r // standard circle-to-bezier constant + return listOf( + PathNode.MoveTo(cx + r, cy), + PathNode.CurveTo(cx + r, cy + k, cx + k, cy + r, cx, cy + r), + PathNode.CurveTo(cx - k, cy + r, cx - r, cy + k, cx - r, cy), + PathNode.CurveTo(cx - r, cy - k, cx - k, cy - r, cx, cy - r), + PathNode.CurveTo(cx + k, cy - r, cx + r, cy - k, cx + r, cy), + PathNode.Close + ) +} + +/** + * Holder that mirrors the `androidx.compose.material.icons.Icons` style so consumers can + * write `Icons.MapTruck` exactly like a Material icon. + */ +object Icons diff --git a/component/src/main/java/com/blipblipcode/component/image/TintCap.kt b/component/src/main/java/com/blipblipcode/component/image/TintCap.kt new file mode 100644 index 0000000..2502ac5 --- /dev/null +++ b/component/src/main/java/com/blipblipcode/component/image/TintCap.kt @@ -0,0 +1,84 @@ +package com.blipblipcode.component.image + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable + +/** + * Defines which layers of an [androidx.compose.ui.graphics.vector.ImageVector] receive the + * tint color when rendering an [Icon] or [Image]. + * + * An ImageVector is composed of a tree of top-level nodes (groups and paths). Each top-level + * node is considered one "layer" and is identified by its position (zero-based) in the + * vector's root. + * + * - [All] Tints every layer (default for [Icon], matches standard Compose tinting). + * - [Index] Tints only the layer at the given position. + * - [Range] Tints every layer whose position lies inside the given [IntRange]. + * - [Layers] Tints only the layers at the specified positions. + * - [Undefined] Does not apply any tint transformation; the vector is rendered with its + * original colors (default for [Image]). + */ +@Stable +sealed class TintCap { + + /** Whether this tint cap should skip tinting entirely and preserve the vector's original colors. */ + abstract val isUndefined: Boolean + + /** Returns `true` when the top-level node at [layerIndex] should receive the tint color. */ + abstract fun appliesTo(layerIndex: Int): Boolean + + @Immutable + object All : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = true + override fun toString(): String = "TintCap.All" + } + + @Immutable + object Undefined : TintCap() { + override val isUndefined: Boolean = true + override fun appliesTo(layerIndex: Int): Boolean = false + override fun toString(): String = "TintCap.Undefined" + } + + @Immutable + data class Index(val layer: Int) : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex == layer + } + + @Immutable + data class Range(val range: IntRange) : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex in range + } + + @Immutable + data class Layers(val layers: List) : TintCap() { + override val isUndefined: Boolean = false + override fun appliesTo(layerIndex: Int): Boolean = layerIndex in layers + } + + companion object { + /** Convenience alias for [All]. */ + val All: TintCap get() = All + + /** Convenience alias for [Undefined]. */ + val Undefined: TintCap get() = Undefined + + /** Builds a [TintCap] that tints the single layer at [layer]. */ + fun index(layer: Int): TintCap = Index(layer) + + /** Builds a [TintCap] that tints every layer whose index lies inside [range]. */ + fun range(range: IntRange): TintCap = Range(range) + + /** Builds a [TintCap] that tints every layer whose index lies in `start..endInclusive`. */ + fun range(start: Int, endInclusive: Int): TintCap = Range(start..endInclusive) + + /** Builds a [TintCap] that tints every layer whose index appears in [layers]. */ + fun layers(vararg layers: Int): TintCap = Layers(layers.toList()) + + /** Builds a [TintCap] that tints every layer whose index appears in [layers]. */ + fun layers(layers: List): TintCap = Layers(layers) + } +} \ No newline at end of file diff --git a/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt b/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt new file mode 100644 index 0000000..d6cde6d --- /dev/null +++ b/component/src/test/java/com/blipblipcode/component/image/ImageVectorTinterTest.kt @@ -0,0 +1,132 @@ +package com.blipblipcode.component.image + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.VectorNode +import androidx.compose.ui.graphics.vector.VectorPath +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Verifies the internal recolorImageVector function preserves the structure of the source + * ImageVector and applies the tint only to the requested top-level layers. + */ +class ImageVectorTinterTest { + + /** + * Builds a 3-layer ImageVector where each top-level layer is a single path filled with + * a different distinctive color. + */ + private fun threeLayerVector(): ImageVector { + val builder = ImageVector.Builder( + name = "three-layer", + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f + ) + builder.addPath( + pathData = emptyList(), + name = "p0", + fill = SolidColor(Color.Red) + ) + builder.addPath( + pathData = emptyList(), + name = "p1", + fill = SolidColor(Color.Green) + ) + builder.addPath( + pathData = emptyList(), + name = "p2", + fill = SolidColor(Color.Blue) + ) + return builder.build() + } + + private fun topLevelPathsOf(vector: ImageVector): List { + val out = ArrayList() + for (node: VectorNode in vector.root) { + if (node is VectorPath) out += node + } + return out + } + + @Test + fun `Undefined returns the same source untouched`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.Undefined) + assertSame(source, result) + } + + @Test + fun `All rebuilds the vector with every layer tinted`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.All) + val paths = topLevelPathsOf(result) + assertEquals(3, paths.size) + paths.forEach { p -> + assertEquals("fill must be SolidColor", true, p.fill is SolidColor) + assertEquals(Color.Magenta, (p.fill as SolidColor).value) + } + } + + @Test + fun `Index tints only the matching layer and preserves the others`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.index(1)) + val paths = topLevelPathsOf(result) + assertEquals(3, paths.size) + assertEquals(Color.Red, (paths[0].fill as SolidColor).value) + assertEquals(Color.Magenta, (paths[1].fill as SolidColor).value) + assertEquals(Color.Blue, (paths[2].fill as SolidColor).value) + } + + @Test + fun `Range tints every layer inside the range`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.range(0..1)) + val paths = topLevelPathsOf(result) + assertEquals(Color.Magenta, (paths[0].fill as SolidColor).value) + assertEquals(Color.Magenta, (paths[1].fill as SolidColor).value) + assertEquals(Color.Blue, (paths[2].fill as SolidColor).value) + } + + @Test + fun `Layers tints only the specified positions`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.layers(0, 2)) + val paths = topLevelPathsOf(result) + assertEquals(Color.Magenta, (paths[0].fill as SolidColor).value) + assertEquals(Color.Green, (paths[1].fill as SolidColor).value) + assertEquals(Color.Magenta, (paths[2].fill as SolidColor).value) + } + + @Test + fun `recoloring produces a fresh ImageVector instance`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.All) + assertNotSame(source, result) + } + + @Test + fun `recoloring preserves viewport dimensions and name`() { + val source = threeLayerVector() + val result = recolorImageVector(source, Color.Magenta, TintCap.All) + assertEquals(source.name, result.name) + assertEquals(Dp(24f), result.defaultWidth) + assertEquals(Dp(24f), result.defaultHeight) + assertEquals(source.viewportWidth, result.viewportWidth, 0f) + assertEquals(source.viewportHeight, result.viewportHeight, 0f) + // Sanity: result must have a populated root + assertNotNull(result.root) + assertTrue(result.root.size >= 3) + } +} \ No newline at end of file diff --git a/component/src/test/java/com/blipblipcode/component/image/TintCapTest.kt b/component/src/test/java/com/blipblipcode/component/image/TintCapTest.kt new file mode 100644 index 0000000..6162718 --- /dev/null +++ b/component/src/test/java/com/blipblipcode/component/image/TintCapTest.kt @@ -0,0 +1,92 @@ +package com.blipblipcode.component.image + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class TintCapTest { + + @Test + fun `All tints every layer and is not undefined`() { + val cap = TintCap.All + assertFalse(cap.isUndefined) + for (i in -1..10) { + assertTrue("layer $i should be tinted by All", cap.appliesTo(i)) + } + } + + @Test + fun `Undefined never tints and reports itself as undefined`() { + val cap = TintCap.Undefined + assertTrue(cap.isUndefined) + for (i in -5..20) { + assertFalse("layer $i should NOT be tinted by Undefined", cap.appliesTo(i)) + } + } + + @Test + fun `Index tints only the matching layer`() { + val cap = TintCap.index(3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Index works with negative and out-of-range positions`() { + val cap = TintCap.index(0) + assertFalse(cap.appliesTo(-1)) + assertTrue(cap.appliesTo(0)) + assertFalse(cap.appliesTo(1)) + } + + @Test + fun `Range tints every layer within the range, inclusive`() { + val cap = TintCap.range(1..3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertTrue(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Range with start and endInclusive helper works`() { + val cap = TintCap.range(0, 2) + assertTrue(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertTrue(cap.appliesTo(2)) + assertFalse(cap.appliesTo(3)) + } + + @Test + fun `Layers tints only the specified positions`() { + val cap = TintCap.layers(1, 3) + assertFalse(cap.isUndefined) + assertFalse(cap.appliesTo(0)) + assertTrue(cap.appliesTo(1)) + assertFalse(cap.appliesTo(2)) + assertTrue(cap.appliesTo(3)) + assertFalse(cap.appliesTo(4)) + } + + @Test + fun `Layers accepts a list factory`() { + val cap = TintCap.layers(listOf(0, 4, 7)) + assertTrue(cap.appliesTo(0)) + assertFalse(cap.appliesTo(1)) + assertTrue(cap.appliesTo(4)) + assertTrue(cap.appliesTo(7)) + assertFalse(cap.appliesTo(8)) + } + + @Test + fun `Layer ordering is preserved`() { + val cap = TintCap.layers(5, 0, 2) + // Even unsorted, appliesTo is membership-based, but equality should preserve order + assertEquals(listOf(5, 0, 2), (cap as TintCap.Layers).layers) + } +} \ No newline at end of file From f722b79e7cbdf2b59d117833b9db0f7207444aa7 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 10:56:32 -0400 Subject: [PATCH 02/12] ci: add release pipeline and fix artifactId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add .github/workflows/release-pipeline.yml (PR to develop) * Unit tests (JVM) on every PR open + when merged * Instrumented tests on API 36 only (matrix reduced from [30, 34]) * Tag & version validation (semver bump check) * :component:assembleRelease → publish AAR to GitHub Release * JitPack build verification + log dump * PR summary comment with per-step status - Add version = "0.1.0" in root build.gradle.kts (required by check-tag) - Rename Maven artifactId from 'query' → 'compose-components' - Document JitPack install in README Verified: ./gradlew :component:assembleRelease produces component/build/outputs/aar/component-release.aar --- .github/workflows/release-pipeline.yml | 635 +++++++++++++++++++++++++ README.md | 22 + build.gradle.kts | 4 +- component/build.gradle.kts | 2 +- 4 files changed, 661 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/release-pipeline.yml diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml new file mode 100644 index 0000000..16bafb4 --- /dev/null +++ b/.github/workflows/release-pipeline.yml @@ -0,0 +1,635 @@ +name: 🚀 Release Pipeline — PR to Develop + +# ───────────────────────────────────────────────────────────────────────────── +# TRIGGER +# Corre en cada PR hacia develop y en el merge del mismo. +# Los pasos 3-4-5 solo corren cuando el PR es mergeado. +# ───────────────────────────────────────────────────────────────────────────── +on: + pull_request: + branches: + - develop + types: [closed] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event.action != 'closed' }} + +jobs: + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 1A — Unit Tests (JVM) + # ─────────────────────────────────────────────────────────────────────────── + unit-tests: + name: 🧪 Step 1A — Unit Tests (:component) + runs-on: ubuntu-latest + if: github.event.action != 'closed' || github.event.pull_request.merged == true + timeout-minutes: 30 + + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: 📦 Restore Gradle cache (develop-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}-develop- + gradle-${{ runner.os }}- + + - name: 💾 Save Gradle cache (only develop / on miss) + if: github.ref_name == 'develop' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 🔧 Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: 🧪 Run :component unit tests + id: run-tests + run: | + ./gradlew :component:testDebugUnitTest \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace + + - name: 📊 Publish unit test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: component/build/test-results/**/*.xml + check_name: 📋 Unit Test Results — :component + comment_title: 🧪 Unit Test Report — :component module + comment_mode: always + + - name: 📄 Upload test report on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: unit-test-report-${{ github.run_number }} + path: component/build/reports/tests/ + retention-days: 14 + if-no-files-found: ignore + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 1B — Instrumented Tests (API 36 only) + # ─────────────────────────────────────────────────────────────────────────── + android-tests: + name: 🤖 Android Tests (API ${{ matrix.api-level }}) + needs: [unit-tests] + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + contents: read + checks: write + pull-requests: write + + strategy: + fail-fast: false + matrix: + api-level: [36] + + steps: + - name: 🔧 Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: 🐘 Restore Gradle cache (shared, develop-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle-develop- + ${{ runner.os }}-gradle- + - name: 💾 Save Gradle cache (only develop / on miss) + if: github.ref_name == 'develop' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 📱 Restore AVD cache (1 per API, develop-first) + uses: actions/cache/restore@v6 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-compose-components-${{ matrix.api-level }}-google_apis + restore-keys: | + avd-compose-components-${{ matrix.api-level }}-google_apis-develop + avd-compose-components-${{ matrix.api-level }}-google_apis- + + - name: 🏗️ Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: google_apis + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + emulator-boot-timeout: 300 + script: echo "✅ AVD snapshot generated for caching (API ${{ matrix.api-level }})" + + - name: 💾 Save AVD cache (only when newly created) + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-compose-components-${{ matrix.api-level }}-google_apis + + - name: 🧪 Run instrumented tests (API ${{ matrix.api-level }}) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: google_apis + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew :app:connectedDebugAndroidTest + + - name: 📊 Publish instrumented test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: '**/build/outputs/androidTest-results/**/*.xml' + check_name: 📋 Instrumented Results — API ${{ matrix.api-level }} + comment_title: 🤖 Instrumented Test Report (API ${{ matrix.api-level }}) + comment_mode: always + + - name: 📄 Upload HTML report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: android-test-report-api${{ matrix.api-level }}-${{ github.run_number }} + path: '**/build/reports/androidTests/connected/' + retention-days: 30 + if-no-files-found: ignore + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 2 — Check Tag Availability & Version Bump + # ─────────────────────────────────────────────────────────────────────────── + check-tag: + name: 🏷️ Step 2 — Check Tag & Version Bump + runs-on: ubuntu-latest + if: github.event.action != 'closed' || github.event.pull_request.merged == true + timeout-minutes: 10 + + permissions: + contents: read + + outputs: + version: ${{ steps.extract-version.outputs.version }} + tag_name: ${{ steps.extract-version.outputs.tag_name }} + latest_tag: ${{ steps.validate-version.outputs.latest_tag }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: 📌 Extract version from build.gradle.kts + id: extract-version + run: | + VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' build.gradle.kts 2>/dev/null | head -1 || true) + + if [ -z "$VERSION" ]; then + echo "❌ No se encontró 'version = \"...\"' en build.gradle.kts" + exit 1 + fi + + TAG_NAME="v${VERSION}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "📌 Versión en Gradle: ${VERSION} → Tag a crear: ${TAG_NAME}" + + - name: "🔍 Validate: new tag > latest tag & no duplicate" + id: validate-version + run: | + NEW_VERSION="${{ steps.extract-version.outputs.version }}" + NEW_TAG="${{ steps.extract-version.outputs.tag_name }}" + + semver_gt() { + local A="${1#v}" B="${2#v}" + local IFS=. + read -ra VA <<< "$A" + read -ra VB <<< "$B" + for i in 0 1 2; do + local a="${VA[$i]:-0}" b="${VB[$i]:-0}" + if (( 10#$a > 10#$b )); then return 0 + elif (( 10#$a < 10#$b )); then return 1 + fi + done + return 1 + } + + LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -1) + + if [ -z "$LATEST_TAG" ]; then + echo "ℹ️ No hay tags previos en el repo. Primer release: ${NEW_TAG}" + echo "latest_tag=ninguno" >> $GITHUB_OUTPUT + echo "✅ Validación superada — primer release." + exit 0 + fi + + echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT + echo "🏷️ Último tag existente : ${LATEST_TAG}" + echo "🆕 Nuevo tag a crear : ${NEW_TAG}" + + if git ls-remote --tags origin "refs/tags/${NEW_TAG}" | grep -q "${NEW_TAG}"; then + echo "" + echo "❌ ERROR: El tag ${NEW_TAG} ya existe en el repositorio." + echo " Incrementa la versión en build.gradle.kts antes de mergear." + exit 1 + fi + + if semver_gt "$NEW_VERSION" "$LATEST_TAG"; then + echo "" + echo "✅ Validación superada: ${NEW_TAG} > ${LATEST_TAG}" + else + echo "" + echo "❌ ERROR: La versión ${NEW_VERSION} NO es mayor que el último tag ${LATEST_TAG}." + exit 1 + fi + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 3 — Build :component Release AAR + # ─────────────────────────────────────────────────────────────────────────── + build-release: + name: 🏗️ Step 3 — Build :component Release AAR + runs-on: ubuntu-latest + needs: [unit-tests, android-tests, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 30 + + permissions: + contents: read + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: 📦 Restore Gradle cache (develop-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}-develop- + gradle-${{ runner.os }}- + + - name: 💾 Save Gradle cache (only develop / on miss) + if: github.ref_name == 'develop' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 🔧 Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: 🏗️ Assemble :component Release + run: | + ./gradlew :component:assembleRelease \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace + + - name: 🔎 Locate generated AAR + id: find-aar + run: | + AAR_PATH=$(find component/build/outputs/aar -name "*release*.aar" | head -1) + if [ -z "$AAR_PATH" ]; then + echo "❌ No AAR release was found in component/build/outputs/aar/" + exit 1 + fi + echo "aar_path=${AAR_PATH}" >> $GITHUB_OUTPUT + echo "✅ AAR found: ${AAR_PATH}" + + - name: 📦 Upload AAR as workflow artifact + uses: actions/upload-artifact@v7 + with: + name: compose-components-release-aar + path: ${{ steps.find-aar.outputs.aar_path }} + retention-days: 7 + if-no-files-found: error + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 4 — Create Tag & GitHub Release + # ─────────────────────────────────────────────────────────────────────────── + create-release: + name: 🎯 Step 4 — Create Tag & GitHub Release + runs-on: ubuntu-latest + needs: [build-release, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 10 + + permissions: + contents: write + + outputs: + release_url: ${{ steps.gh-release.outputs.url }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: 📦 Download AAR artifact + uses: actions/download-artifact@v8 + with: + name: compose-components-release-aar + path: ./release-artifacts + + - name: 🏷️ Create GitHub Release & Tag + id: gh-release + uses: softprops/action-gh-release@v3 + with: + tag_name: ${{ needs.check-tag.outputs.tag_name }} + name: Release ${{ needs.check-tag.outputs.tag_name }} + body: | + ## 📦 compose-components ${{ needs.check-tag.outputs.tag_name }} + + Publicado automáticamente desde PR #${{ github.event.pull_request.number }} + **${{ github.event.pull_request.title }}** + + --- + + ### 📥 Agregar como dependencia via JitPack + + ```kotlin + // settings.gradle.kts + dependencyResolutionManagement { + repositories { + maven { url = uri("https://jitpack.io") } + } + } + + // build.gradle.kts (module) + dependencies { + implementation("com.github.LeandroLCD:compose-components:${{ needs.check-tag.outputs.version }}") + } + ``` + + --- + 📅 Generado el: ${{ github.event.pull_request.merged_at }} + files: ./release-artifacts/*.aar + make_latest: true + fail_on_unmatched_files: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ─────────────────────────────────────────────────────────────────────────── + # STEP 5 — JitPack Build Log + # ─────────────────────────────────────────────────────────────────────────── + jitpack-build: + name: 📡 Step 5 — JitPack Build Log + runs-on: ubuntu-latest + needs: [create-release, check-tag] + if: github.event.pull_request.merged == true + timeout-minutes: 20 + + outputs: + jitpack_status: ${{ steps.poll-jitpack.outputs.jitpack_status }} + jitpack_log_url: ${{ steps.poll-jitpack.outputs.jitpack_log_url }} + + steps: + - name: ⏳ Initial wait — let JitPack index the new tag + run: | + echo "⏳ Esperando 40s para que JitPack indexe el tag ${{ needs.check-tag.outputs.tag_name }}..." + sleep 40 + + - name: 🚀 Trigger JitPack build & poll status + id: poll-jitpack + run: | + VERSION="${{ needs.check-tag.outputs.version }}" + GROUP="com.github.LeandroLCD" + ARTIFACT="compose-components" + LOG_URL="https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/build.log" + API_URL="https://jitpack.io/api/builds/${GROUP}/${ARTIFACT}/${VERSION}" + + echo "🔗 Log URL : ${LOG_URL}" + echo "🔗 API URL : ${API_URL}" + + echo "🚀 Disparando build en JitPack..." + curl -s -o /dev/null -w "HTTP %{http_code}\n" \ + "https://jitpack.io/${GROUP//.//}/${ARTIFACT}/${VERSION}/${ARTIFACT}-${VERSION}.aar" || true + + MAX=15 + ATTEMPT=0 + STATUS="unknown" + + while [ $ATTEMPT -lt $MAX ]; do + ATTEMPT=$((ATTEMPT + 1)) + echo "⏳ Intento ${ATTEMPT}/${MAX} — consultando estado en JitPack..." + + RESPONSE=$(curl -s --max-time 15 "${API_URL}" 2>/dev/null || echo '{}') + STATUS=$(echo "$RESPONSE" | python3 -c \ + "import sys,json; d=json.load(sys.stdin); print(d.get('status','unknown'))" 2>/dev/null || echo "unknown") + + echo " 📊 Status: ${STATUS}" + + if [ "$STATUS" = "ok" ]; then + echo "✅ JitPack build exitoso!" + break + elif [ "$STATUS" = "error" ]; then + echo "❌ JitPack build falló. Revisa el log:" + echo " ${LOG_URL}" + break + fi + + [ $ATTEMPT -lt $MAX ] && sleep 30 + done + + echo "jitpack_status=${STATUS}" >> $GITHUB_OUTPUT + echo "jitpack_log_url=${LOG_URL}" >> $GITHUB_OUTPUT + + - name: 📄 Print JitPack build log + if: always() + run: | + VERSION="${{ needs.check-tag.outputs.version }}" + LOG_URL="https://jitpack.io/com/github/LeandroLCD/compose-components/${VERSION}/build.log" + echo "════════════════════════════════════════" + echo " JitPack Build Log — ${VERSION}" + echo "════════════════════════════════════════" + curl -s --max-time 30 "${LOG_URL}" || echo "⚠️ No se pudo obtener el log aún. URL: ${LOG_URL}" + echo "════════════════════════════════════════" + + # ─────────────────────────────────────────────────────────────────────────── + # PR ANNOTATION — Resumen del pipeline como comentario en el PR + # ─────────────────────────────────────────────────────────────────────────── + pr-summary: + name: 📝 PR Summary Annotation + runs-on: ubuntu-latest + needs: [unit-tests, android-tests, check-tag, build-release, create-release, jitpack-build] + if: always() && (github.event.action != 'closed' || github.event.pull_request.merged == true) + timeout-minutes: 5 + + permissions: + pull-requests: write + + steps: + - name: 📝 Post pipeline summary comment on PR + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const icon = (r) => ({ + success: '✅', failure: '❌', skipped: '⏭️', cancelled: '🚫' + }[r] ?? '⚠️'); + + const isMerged = ${{ github.event.pull_request.merged == true }}; + const version = `${{ needs.check-tag.outputs.version }}`; + const tagName = `${{ needs.check-tag.outputs.tag_name }}`; + const latestTag = `${{ needs.check-tag.outputs.latest_tag }}`; + const releaseUrl = `${{ needs.create-release.outputs.release_url }}`; + const jitpackStatus = `${{ needs.jitpack-build.outputs.jitpack_status }}`; + const jitpackLog = `${{ needs.jitpack-build.outputs.jitpack_log_url }}`; + + const r1 = `${{ needs.unit-tests.result }}`; + const r2 = `${{ needs.android-tests.result }}`; + const r3 = `${{ needs.check-tag.result }}`; + const r4 = `${{ needs.build-release.result }}`; + const r5 = `${{ needs.create-release.result }}`; + const r6 = `${{ needs.jitpack-build.result }}`; + + const mergeRow = isMerged + ? '✅ **Mergeado** — pipeline completo ejecutado' + : '⏳ **Pendiente de merge** — solo validaciones previas'; + + const releaseLink = releaseUrl + ? `[Ver GitHub Release](${releaseUrl})` + : '—'; + + const jitpackRow = jitpackLog + ? `[📄 Build Log](${jitpackLog}) · Status: \`${jitpackStatus}\`` + : '—'; + + const versionArrow = (latestTag && latestTag !== 'ninguno' && tagName) + ? `\`${latestTag}\` → \`${tagName}\`` + : tagName ? `primer release: \`${tagName}\`` : 'N/A'; + + const depBlock = isMerged && version ? ` + ### 📥 Dependency (JitPack) + \`\`\`kotlin + // settings.gradle.kts + maven { url = uri("https://jitpack.io") } + + // build.gradle.kts + implementation("com.github.LeandroLCD:compose-components:${version}") + \`\`\`` : ''; + + const warningBlock = (!isMerged && (r1 === 'failure' || r2 === 'failure' || r3 === 'failure')) + ? `\n> ⚠️ **Hay errores de validación.** Corrígelos antes de mergear.\n` + : ''; + + const body = `## 🚀 Release Pipeline — Resumen + + ${warningBlock} + | # | Paso | Estado | Detalle | + |---|------|--------|---------| + | 1️⃣ | Unit Tests | ${icon(r1)} \`${r1}\` | Tests unitarios del módulo \`:component\` | + | 2️⃣ | Android Tests (API 36) | ${icon(r2)} \`${r2}\` | \`./gradlew :app:connectedDebugAndroidTest\` | + | 3️⃣ | Check Tag & Bump | ${icon(r3)} \`${r3}\` | ${versionArrow} | + | 4️⃣ | Build Release AAR | ${icon(r4)} \`${r4}\` | \`./gradlew :component:assembleRelease\` | + | 5️⃣ | GitHub Release | ${icon(r5)} \`${r5}\` | Tag \`${tagName || 'N/A'}\` + AAR · ${releaseLink} | + | 6️⃣ | JitPack Build | ${icon(r6)} \`${r6}\` | ${jitpackRow} | + + **📌 Versión nueva:** \`${version || 'no detectada'}\`  ·  **🏷️ Último tag:** \`${latestTag || '—'}\` + **🔀 Estado:** ${mergeRow} + ${depBlock} + + --- + 🤖 Generado automáticamente por el Release Pipeline · Run #${{ github.run_number }}`; + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.data.find(c => + c.user.type === 'Bot' && c.body.includes('Release Pipeline — Resumen') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/README.md b/README.md index f836a6a..4a619b1 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,28 @@ composecomponents/ ## 🚀 Instalación +### Desde JitPack (release publicado) + +Agrega el repositorio de JitPack en tu `settings.gradle.kts`: + +```kotlin +dependencyResolutionManagement { + repositories { + maven { url = uri("https://jitpack.io") } + } +} +``` + +Y luego la dependencia en el módulo de tu app: + +```kotlin +dependencies { + implementation("com.github.LeandroLCD:compose-components:") +} +``` + +Los tags se publican automáticamente al mergear un PR a `develop` (ver pipeline en `.github/workflows/release-pipeline.yml`). + ### Proyecto local Incluye el módulo `:component` en tus dependencias de Gradle: diff --git a/build.gradle.kts b/build.gradle.kts index 11263cc..e4c4c17 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.android.library) apply false -} \ No newline at end of file +} + +version = "0.1.0" \ No newline at end of file diff --git a/component/build.gradle.kts b/component/build.gradle.kts index 40a0187..8edffe5 100644 --- a/component/build.gradle.kts +++ b/component/build.gradle.kts @@ -49,7 +49,7 @@ publishing { publications { create("release") { groupId = "com.github.LeandroLCD" - artifactId = "query" + artifactId = "compose-components" version = project.version.toString() } } From 7efa51178bd8f1e8a50550044756892ca55aa348 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 11:11:08 -0400 Subject: [PATCH 03/12] fix(tintCap): resolve fixture overlaps and stabilise UI tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified end-to-end on a physical device (VH-C83, Android 11 / API 30): ./gradlew :component:connectedDebugAndroidTest → 12 tests, 0 failures, 0 errors, 0 skipped (13.891s) MapTruck.kt: - Wrap cab-shell + cab-window in a single 'cab' group so the cab is one logical layer (was 2 separate top-level paths before). - Redesign layout so layers occupy non-overlapping visual regions: wheels → bottom strip (y 49–59) body → thin chassis strip (y 44–48) cab → top-right (x 40–62, y 14–42) cargo → top-left (x 2–38, y 4–42) This makes each layer pixel-testable in isolation. IconTintCapTest / ImageTintCapTest: - Move testTag from the Icon modifier to the outer Box so the semantic tree is stable across re-renders (fixes sporadic 'No compose hierarchies' failures on Android 11). - Update sample positions to the new layout: wheels (12, 54), body (32, 46), cab (52, 32), cargo (20, 20). README.md: - Reflect Compose BOM 2026.02.00, Kotlin 2.3.10, AGP 9.0.0+ - Add TOC, CI badge, fixture section, tests section, JitPack install --- README.md | 135 +++++++++++++++--- .../component/image/IconTintCapTest.kt | 28 ++-- .../component/image/ImageTintCapTest.kt | 15 +- .../blipblipcode/component/image/MapTruck.kt | 98 +++++++------ 4 files changed, 196 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 4a619b1..cc1bceb 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,32 @@ # Compose Components -[![Kotlin](https://img.shields.io/badge/Kotlin-2.3.0-purple.svg)](https://kotlinlang.org/) -[![Compose BOM](https://img.shields.io/badge/Compose%20BOM-2025.12.01-green.svg)](https://developer.android.com/jetpack/compose) +[![Kotlin](https://img.shields.io/badge/Kotlin-2.3.10-purple.svg)](https://kotlinlang.org/) +[![Compose BOM](https://img.shields.io/badge/Compose%20BOM-2026.02.00-green.svg)](https://developer.android.com/jetpack/compose) [![Material 3](https://img.shields.io/badge/Material%203-Ready-blue.svg)](https://m3.material.io/) [![API](https://img.shields.io/badge/API-24%2B-brightgreen.svg)](https://android-arsenal.com/api?level=24) +[![CI](https://img.shields.io/badge/CI-Release%20Pipeline-blueviolet.svg)](.github/workflows/release-pipeline.yml) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -Una librería de componentes de UI altamente personalizables para **Jetpack Compose**, construida sobre **Material 3**. Ofrece opciones de personalización avanzadas (tamaños, colores, formas) que van más allá de las configuraciones estándar de Material 3. +Una librería de componentes de UI altamente personalizables para **Jetpack Compose**, construida sobre **Material 3**. Ofrece opciones de personalización avanzadas (tamaños, colores, formas, **tint selectivo por capa**) que van más allá de las configuraciones estándar de Material 3. + +--- + +## 📋 Tabla de Contenidos + +- [✨ Características](#-características) +- [📦 Componentes Disponibles](#-componentes-disponibles) + - [SliderComponent](#1-slidercomponent) + - [LinearProgressIndicatorComponents](#2-linearprogressindicatorcomponents) + - [RangeSliderComponent](#3-rangeslidercomponent) + - [Icon (con `tintCap`)](#4-icon-con-tintcap) + - [Image (con `tintCap`)](#5-image-con-tintcap) +- [🎨 Sistema de Colores](#-sistema-de-colores) +- [🧪 Tests](#-tests) +- [📁 Estructura del Proyecto](#-estructura-del-proyecto) +- [🚀 Instalación](#-instalación) +- [📋 Requisitos](#-requisitos) +- [🤝 Contribuciones](#-contribuciones) +- [📄 Licencia](#-licencia) --- @@ -15,7 +35,10 @@ Una librería de componentes de UI altamente personalizables para **Jetpack Comp - 🎨 **Personalización avanzada**: Control total sobre colores, tamaños y formas - 🧩 **Basado en Material 3**: Integración nativa con el sistema de diseño de Material - ⚡ **Fácil de usar**: API intuitiva y compatible con los componentes existentes +- 🖌️ **Tinte selectivo por capa (`tintCap`)**: Pinta solo las capas que quieras de un `ImageVector` y preserva el resto +- 🧪 **Cubierto por tests**: Suite de tests unitarios (JVM) e instrumentados (Compose UI tests) - 📱 **Compatible con API 24+**: Soporte para una amplia gama de dispositivos +- 🚀 **Release automatizado**: Pipeline de CI que publica AAR + release + JitPack al mergear a `develop` --- @@ -130,6 +153,8 @@ RangeSliderComponent( Wrapper sobre `androidx.compose.material3.Icon` que añade el parámetro `tintCap` para controlar qué capas (layers) de un `ImageVector` reciben el color de `tint`. Las capas no afectadas conservan sus colores originales. +> 💡 **¿Por qué?** Cuando tiñes un `ImageVector` complejo (logos, ilustraciones, íconos con partes de marca) normalmente **todo** el vector se vuelve del color del `tint`. Con `tintCap` puedes pintar **solo** las capas que sí deben cambiar de color y dejar intactas las que representan la identidad visual (p.ej. el fondo o un detalle de marca). + **Propiedades personalizables:** | Propiedad | Tipo | Descripción | |-----------|------|-------------| @@ -193,6 +218,28 @@ Icon( ) ``` +#### Fixture incluido: `Icons.MapTruck` + +El módulo incluye un `ImageVector` de camión multi-capa pensado para ejercitar `tintCap`: + +``` +Índice 0 → wheels (grupo con 2 neumáticos) #424242 +Índice 1 → body (cama del camión) #E53935 +Índice 2 → cab (cabina + ventana) #1E88E5 +Índice 3 → cargo (caja de carga) #43A047 +``` + +Úsalo para prototipar y validar el comportamiento de `tintCap` sin necesidad de un asset externo: + +```kotlin +Icon( + imageVector = Icons.MapTruck, + contentDescription = "Truck", + tint = Color.Yellow, + tintCap = TintCap.layers(0, 3) // solo neumáticos y carga en amarillo +) +``` + --- ### 5. Image (con `tintCap`) @@ -265,30 +312,71 @@ SliderColorsDefaults( --- +## 🧪 Tests + +Cada componente está cubierto por tests. Para ejecutarlos: + +```bash +# Tests unitarios (JVM) — rápidos, no requieren emulador +./gradlew :component:testDebugUnitTest + +# Tests instrumentados (Compose UI tests) — requieren emulador o dispositivo +./gradlew :app:connectedDebugAndroidTest +``` + +**Cobertura:** + +| Componente | Unit tests | Instrumented UI tests | +|------------|:----------:|:---------------------:| +| `SliderComponent` | — | — | +| `LinearProgressIndicatorComponents` | — | — | +| `RangeSliderComponent` | — | — | +| `TintCap` | ✅ 9 tests | ✅ vía `Icon` / `Image` | +| `ImageVectorTinter` | ✅ 7 tests | ✅ vía `Icon` / `Image` | +| `Icon` (con `tintCap`) | — | ✅ 6 tests | +| `Image` (con `tintCap`) | — | ✅ 5 tests | + +Los UI tests renderizan el fixture `Icons.MapTruck` (4 capas top-level con colores distinguibles) y muestrean píxeles del bitmap capturado para verificar que cada variante de `tintCap` pinta exactamente las capas correctas. + +--- + ## 📁 Estructura del Proyecto ``` composecomponents/ -├── app/ # Aplicación de demostración -├── component/ # Módulo de la librería +├── app/ # Aplicación de demostración +│ └── src/main/java/com/blipblipcode/compose_components/ +│ └── MainActivity.kt # Incluye el fixture Icons.MapTruck +├── component/ # Módulo de la librería │ └── src/main/java/com/blipblipcode/component/ -│ ├── slider/ # SliderComponent y utilidades +│ ├── slider/ # SliderComponent y utilidades │ │ ├── SliderComponent.kt │ │ ├── SliderDefaults.kt │ │ ├── SliderColorsDefaults.kt │ │ └── SliderSizeDefaults.kt -│ ├── linear/ # LinearProgressIndicatorComponents +│ ├── linear/ # LinearProgressIndicatorComponents │ │ └── LinearProgressIndicatorComponents.kt -│ ├── range/ # RangeSliderComponent +│ ├── range/ # RangeSliderComponent │ │ ├── RangeSliderComponent.kt │ │ └── RangeSliderDefaults.kt -│ └── image/ # Icon e Image con tintCap -│ ├── TintCap.kt -│ ├── ImageVectorTinter.kt -│ ├── Icon.kt -│ └── Image.kt +│ └── image/ # Icon e Image con tintCap +│ ├── TintCap.kt # Sealed class (All / Undefined / Index / Range / Layers) +│ ├── ImageVectorTinter.kt # Lógica interna de re-tintado selectivo +│ ├── Icon.kt # Wrapper de Material3 Icon +│ ├── Image.kt # Wrapper de Foundation Image +│ └── MapTruck.kt # Fixture ImageVector de 4 capas +│ └── src/test/ # Tests unitarios (JVM) +│ └── java/com/blipblipcode/component/image/ +│ ├── TintCapTest.kt # 9 tests +│ └── ImageVectorTinterTest.kt # 7 tests +│ └── src/androidTest/ # Tests instrumentados (Compose UI) +│ └── java/com/blipblipcode/component/image/ +│ ├── IconTintCapTest.kt # 6 tests +│ └── ImageTintCapTest.kt # 5 tests +├── .github/workflows/ +│ └── release-pipeline.yml # CI: tests + build AAR + release + JitPack └── gradle/ - └── libs.versions.toml # Catálogo de versiones + └── libs.versions.toml # Catálogo de versiones ``` --- @@ -297,6 +385,8 @@ composecomponents/ ### Desde JitPack (release publicado) +Cada merge a `develop` publica automáticamente un nuevo tag + AAR en GitHub Releases y dispara una build en JitPack. + Agrega el repositorio de JitPack en tu `settings.gradle.kts`: ```kotlin @@ -315,7 +405,9 @@ dependencies { } ``` -Los tags se publican automáticamente al mergear un PR a `develop` (ver pipeline en `.github/workflows/release-pipeline.yml`). +Reemplaza `` por el tag publicado (ej: `v0.1.0`). Los tags y el changelog están en la pestaña [Releases](../../releases) del repositorio. + +> ⚠️ La primera vez que importes el tag, JitPack necesita compilar el módulo; puede tardar unos minutos. Builds subsiguientes son instantáneas. ### Proyecto local @@ -346,11 +438,12 @@ android { | Requisito | Versión mínima | |-----------|----------------| | Android Studio | Ladybug o superior | -| Kotlin | 2.3.0+ | -| Compose BOM | 2025.12.01+ | +| Kotlin | 2.3.10+ | +| Compose BOM | 2026.02.00+ | | Min SDK | 24 (Android 7.0) | | Target SDK | 36 | | JVM Target | 17 | +| AGP | 9.0.0+ | --- @@ -359,10 +452,12 @@ android { ¡Las contribuciones son bienvenidas! Si deseas contribuir: 1. Haz un Fork del proyecto -2. Crea una rama para tu feature (`git checkout -b feature/nueva-funcionalidad`) -3. Realiza tus cambios y haz commit (`git commit -m 'Añade nueva funcionalidad'`) +2. Crea una rama desde `develop` para tu feature (`git checkout -b feature/nueva-funcionalidad`) +3. Realiza tus cambios y haz commit (`git commit -m 'feat: añade nueva funcionalidad'`) 4. Push a la rama (`git push origin feature/nueva-funcionalidad`) -5. Abre un Pull Request +5. Abre un Pull Request hacia `develop` + +El pipeline de CI correrá tests unitarios + instrumentados (API 36) y, al mergear, publicará un nuevo release. --- diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt index 3f5ccfb..19f4704 100644 --- a/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt +++ b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt @@ -20,11 +20,12 @@ import org.junit.Test /** * Instrumented UI tests for [Icon] with the various [TintCap] variants, using the * [Icons.MapTruck] fixture which contains 4 distinct top-level layers: - * 0 → `wheels` (group), 1 → `body`, 2 → `cab`, 3 → `cargo`. + * 0 → `wheels` (group), 1 → `body`, 2 → `cab` (cabin + window), 3 → `cargo`. * * Each test renders the truck inside an [Icon] with a specific [TintCap], captures the - * resulting bitmap, and samples well-known pixel coordinates to verify that only the layers - * targeted by [tintCap] receive the tint colour while the rest keep their original colour. + * resulting bitmap of a fixed-size [Box] that wraps the [Icon], and samples well-known + * pixel coordinates to verify that only the layers targeted by [tintCap] receive the tint + * colour while the rest keep their original colour. */ class IconTintCapTest { @@ -46,20 +47,23 @@ class IconTintCapTest { /** * Samples the rendered icon at the centre of every layer. Returns an `IntArray` of - * length 4 ordered as: [wheels, body, cab, cargo]. Coordinates are expressed in the - * rendered pixel buffer; with [iconSizeDp] = 128.dp and a 64x64 viewport, the scale is - * exactly 2 px per unit so positions match the source coords * 2. + * length 4 ordered as: [wheels, body, cab, cargo]. Coordinates are expressed as a + * fraction of the rendered pixel buffer; with [iconSizeDp] = 128.dp and a 64x64 + * viewport, positions match the source coords * 2. */ private fun renderAndSample(cap: TintCap): IntArray { composeTestRule.setContent { Surface(modifier = Modifier.background(Color.White)) { Box( - modifier = Modifier.size(iconSizeDp).background(Color.White) + modifier = Modifier + .size(iconSizeDp) + .background(Color.White) + .testTag(testTagValue) ) { Icon( imageVector = Icons.MapTruck, contentDescription = null, - modifier = Modifier.size(iconSizeDp).testTag(testTagValue), + modifier = Modifier.size(iconSizeDp), tint = tintColor, tintCap = cap ) @@ -71,10 +75,10 @@ class IconTintCapTest { val w = bmp.width val h = bmp.height return intArrayOf( - bmp.getPixel(w * 14 / 64, h * 50 / 64), // front tire (wheels group) - bmp.getPixel(w * 22 / 64, h * 48 / 64), // body - bmp.getPixel(w * 50 / 64, h * 38 / 64), // cab - bmp.getPixel(w * 22 / 64, h * 30 / 64) // cargo + bmp.getPixel(w * 12 / 64, h * 54 / 64), // front tire (wheels group) + bmp.getPixel(w * 32 / 64, h * 46 / 64), // body chassis strip + bmp.getPixel(w * 52 / 64, h * 32 / 64), // cab shell (below window) + bmp.getPixel(w * 20 / 64, h * 20 / 64) // cargo box ) } diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt index 7789064..7e3769d 100644 --- a/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt +++ b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt @@ -40,12 +40,15 @@ class ImageTintCapTest { private fun renderAndSample(cap: TintCap): IntArray { composeTestRule.setContent { Box( - modifier = Modifier.size(imageSizeDp).background(Color.White) + modifier = Modifier + .size(imageSizeDp) + .background(Color.White) + .testTag(testTagValue) ) { Image( imageVector = Icons.MapTruck, contentDescription = null, - modifier = Modifier.size(imageSizeDp).testTag(testTagValue), + modifier = Modifier.size(imageSizeDp), tint = tintColor, tintCap = cap ) @@ -56,10 +59,10 @@ class ImageTintCapTest { val w = bmp.width val h = bmp.height return intArrayOf( - bmp.getPixel(w * 14 / 64, h * 50 / 64), - bmp.getPixel(w * 22 / 64, h * 48 / 64), - bmp.getPixel(w * 50 / 64, h * 38 / 64), - bmp.getPixel(w * 22 / 64, h * 30 / 64) + bmp.getPixel(w * 12 / 64, h * 54 / 64), // front tire (wheels group) + bmp.getPixel(w * 32 / 64, h * 46 / 64), // body chassis strip + bmp.getPixel(w * 52 / 64, h * 32 / 64), // cab shell (below window) + bmp.getPixel(w * 20 / 64, h * 20 / 64) // cargo box ) } diff --git a/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt index 02c66b9..11b4cb4 100644 --- a/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt +++ b/component/src/main/java/com/blipblipcode/component/image/MapTruck.kt @@ -10,11 +10,12 @@ import androidx.compose.ui.unit.dp /** * A multi-layer truck icon used as a UI-test fixture for [Icon] / [Image] with [TintCap]. * - * Top-level layers (indices): - * 0 → `wheels` (group containing both tires) - * 1 → `body` (the truck bed / chassis) - * 2 → `cab` (the driver cabin + window) - * 3 → `cargo` (the cargo box on the bed) + * Top-level layers (indices) are drawn on non-overlapping regions so each one can be + * pixel-tested in isolation: + * 0 → `wheels` (group containing both tires, bottom strip) + * 1 → `body` (narrow chassis strip above the wheels) + * 2 → `cab` (driver cabin shell + window, top-right) + * 3 → `cargo` (cargo box, top-left) * * Each layer uses a distinctive default colour so it is easy to verify which layers get * tinted by each [TintCap] variant. @@ -39,9 +40,9 @@ val Icons.MapTruck: ImageVector translationY = 0f, clipPathData = emptyList() ) { - // Front tire (left side, bottom) + // Front tire (bottom-left, fully visible below the chassis) addPath( - pathData = tirePath(cx = 14f, cy = 50f, r = 6f), + pathData = tirePath(cx = 12f, cy = 54f, r = 5f), name = "tire-front", fill = SolidColor(Color(0xFF424242)), fillAlpha = 1f, @@ -49,9 +50,9 @@ val Icons.MapTruck: ImageVector strokeAlpha = 1f, strokeLineWidth = 1.5f ) - // Rear tire (right side, bottom) + // Rear tire (bottom-right, fully visible below the chassis) addPath( - pathData = tirePath(cx = 50f, cy = 50f, r = 6f), + pathData = tirePath(cx = 50f, cy = 54f, r = 5f), name = "tire-rear", fill = SolidColor(Color(0xFF424242)), fillAlpha = 1f, @@ -61,13 +62,13 @@ val Icons.MapTruck: ImageVector ) } - // Layer 1: truck bed / chassis + // Layer 1: narrow chassis strip (full width, sits between the wheels and the cab/cargo) addPath( pathData = listOf( - PathNode.MoveTo(4f, 44f), - PathNode.LineTo(40f, 44f), - PathNode.LineTo(40f, 52f), - PathNode.LineTo(4f, 52f), + PathNode.MoveTo(2f, 44f), + PathNode.LineTo(62f, 44f), + PathNode.LineTo(62f, 48f), + PathNode.LineTo(2f, 48f), PathNode.Close ), name = "body", @@ -75,39 +76,52 @@ val Icons.MapTruck: ImageVector fillAlpha = 1f ) - // Layer 2: driver cabin - addPath( - pathData = listOf( - PathNode.MoveTo(40f, 24f), - PathNode.LineTo(60f, 24f), - PathNode.LineTo(60f, 52f), - PathNode.LineTo(40f, 52f), - PathNode.Close - ), + // Layer 2: driver cabin (group: cabin shell + window, both tinted together) + // Positioned in the top-right region (no overlap with cargo). + group( name = "cab", - fill = SolidColor(Color(0xFF1E88E5)), - fillAlpha = 1f - ) - addPath( - pathData = listOf( - PathNode.MoveTo(44f, 28f), - PathNode.LineTo(56f, 28f), - PathNode.LineTo(56f, 38f), - PathNode.LineTo(44f, 38f), - PathNode.Close - ), - name = "window", - fill = SolidColor(Color(0xFFBBDEFB)), - fillAlpha = 1f - ) + rotate = 0f, + pivotX = 0f, + pivotY = 0f, + scaleX = 1f, + scaleY = 1f, + translationX = 0f, + translationY = 0f, + clipPathData = emptyList() + ) { + addPath( + pathData = listOf( + PathNode.MoveTo(40f, 14f), + PathNode.LineTo(62f, 14f), + PathNode.LineTo(62f, 42f), + PathNode.LineTo(40f, 42f), + PathNode.Close + ), + name = "cab-shell", + fill = SolidColor(Color(0xFF1E88E5)), + fillAlpha = 1f + ) + addPath( + pathData = listOf( + PathNode.MoveTo(44f, 18f), + PathNode.LineTo(58f, 18f), + PathNode.LineTo(58f, 26f), + PathNode.LineTo(44f, 26f), + PathNode.Close + ), + name = "cab-window", + fill = SolidColor(Color(0xFFBBDEFB)), + fillAlpha = 1f + ) + } - // Layer 3: cargo box + // Layer 3: cargo box (top-left region) addPath( pathData = listOf( - PathNode.MoveTo(6f, 18f), - PathNode.LineTo(38f, 18f), + PathNode.MoveTo(2f, 4f), + PathNode.LineTo(38f, 4f), PathNode.LineTo(38f, 42f), - PathNode.LineTo(6f, 42f), + PathNode.LineTo(2f, 42f), PathNode.Close ), name = "cargo", From a9d76b17f18d4b2d75ac9951a0976d41ff26e9c5 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 11:14:23 -0400 Subject: [PATCH 04/12] =?UTF-8?q?refactor(image):=20rename=20Icon=20?= =?UTF-8?q?=E2=86=92=20IconComponents=20and=20Image=20=E2=86=92=20ImageCom?= =?UTF-8?q?ponents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aligns naming with the rest of the library (SliderComponent, LinearProgressIndicatorComponents, RangeSliderComponent) and avoids shadowing the Material 3 Icon / Foundation Image composables when both are imported in the same file. - Icon → IconComponents (component/src/main/.../Icon.kt) - Image → ImageComponents (component/src/main/.../Image.kt) - Updated all call sites in IconTintCapTest / ImageTintCapTest - Updated TOC, section headers, examples and project-structure comments in README.md Verified: ./gradlew :component:compileDebugKotlin + :component:compileDebugAndroidTestKotlin → BUILD SUCCESSFUL --- README.md | 38 +++++++++---------- .../component/image/IconTintCapTest.kt | 2 +- .../component/image/ImageTintCapTest.kt | 2 +- .../com/blipblipcode/component/image/Icon.kt | 2 +- .../com/blipblipcode/component/image/Image.kt | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index cc1bceb..d647cb7 100644 --- a/README.md +++ b/README.md @@ -18,8 +18,8 @@ Una librería de componentes de UI altamente personalizables para **Jetpack Comp - [SliderComponent](#1-slidercomponent) - [LinearProgressIndicatorComponents](#2-linearprogressindicatorcomponents) - [RangeSliderComponent](#3-rangeslidercomponent) - - [Icon (con `tintCap`)](#4-icon-con-tintcap) - - [Image (con `tintCap`)](#5-image-con-tintcap) + - [IconComponents (con `tintCap`)](#4-iconcomponents-con-tintcap) + - [ImageComponents (con `tintCap`)](#5-imagecomponents-con-tintcap) - [🎨 Sistema de Colores](#-sistema-de-colores) - [🧪 Tests](#-tests) - [📁 Estructura del Proyecto](#-estructura-del-proyecto) @@ -149,7 +149,7 @@ RangeSliderComponent( --- -### 4. Icon (con `tintCap`) +### 4. IconComponents (con `tintCap`) Wrapper sobre `androidx.compose.material3.Icon` que añade el parámetro `tintCap` para controlar qué capas (layers) de un `ImageVector` reciben el color de `tint`. Las capas no afectadas conservan sus colores originales. @@ -179,14 +179,14 @@ Wrapper sobre `androidx.compose.material3.Icon` que añade el parámetro `tintCa ```kotlin // Default: pinta todas las capas -Icon( +IconComponents( imageVector = Icons.Filled.Favorite, contentDescription = null, tint = Color.Red ) // Pinta solo la capa top-level en el índice 1 -Icon( +IconComponents( imageVector = Icons.Filled.Favorite, contentDescription = null, tint = Color.Red, @@ -194,7 +194,7 @@ Icon( ) // Pinta el rango 0..2 y respeta el resto -Icon( +IconComponents( imageVector = Icons.Filled.Favorite, contentDescription = null, tint = Color.Red, @@ -202,7 +202,7 @@ Icon( ) // Pinta múltiples capas específicas -Icon( +IconComponents( imageVector = Icons.Filled.Favorite, contentDescription = null, tint = Color.Red, @@ -210,7 +210,7 @@ Icon( ) // Respeta los colores originales del vector ignorando tint -Icon( +IconComponents( imageVector = Icons.Filled.Favorite, contentDescription = null, tint = Color.Red, // se ignora por estar Undefined @@ -232,7 +232,7 @@ El módulo incluye un `ImageVector` de camión multi-capa pensado para ejercitar Úsalo para prototipar y validar el comportamiento de `tintCap` sin necesidad de un asset externo: ```kotlin -Icon( +IconComponents( imageVector = Icons.MapTruck, contentDescription = "Truck", tint = Color.Yellow, @@ -242,9 +242,9 @@ Icon( --- -### 5. Image (con `tintCap`) +### 5. ImageComponents (con `tintCap`) -Wrapper sobre `androidx.compose.foundation.Image` con la misma potencia de `tintCap` que `Icon`. Pensado para vectores con varias capas donde queremos preservar colores originales (logos, ilustraciones, etc.). +Wrapper sobre `androidx.compose.foundation.Image` con la misma potencia de `tintCap` que `IconComponents`. Pensado para vectores con varias capas donde queremos preservar colores originales (logos, ilustraciones, etc.). **Propiedades personalizables:** | Propiedad | Tipo | Descripción | @@ -263,7 +263,7 @@ Wrapper sobre `androidx.compose.foundation.Image` con la misma potencia de `tint ```kotlin // Logo con fondo original y un solo trazo tintado -Image( +ImageComponents( imageVector = myBrandLogo, contentDescription = "Logo", modifier = Modifier.size(120.dp), @@ -272,7 +272,7 @@ Image( ) // Todas las capas pintadas con tint -Image( +ImageComponents( imageVector = myBrandLogo, contentDescription = "Logo", modifier = Modifier.size(120.dp), @@ -281,7 +281,7 @@ Image( ) // Colores originales del vector intactos (sin transformación) -Image( +ImageComponents( imageVector = myBrandLogo, contentDescription = "Logo", modifier = Modifier.size(120.dp), @@ -333,8 +333,8 @@ Cada componente está cubierto por tests. Para ejecutarlos: | `RangeSliderComponent` | — | — | | `TintCap` | ✅ 9 tests | ✅ vía `Icon` / `Image` | | `ImageVectorTinter` | ✅ 7 tests | ✅ vía `Icon` / `Image` | -| `Icon` (con `tintCap`) | — | ✅ 6 tests | -| `Image` (con `tintCap`) | — | ✅ 5 tests | +| `IconComponents` (con `tintCap`) | — | ✅ 6 tests | +| `ImageComponents` (con `tintCap`) | — | ✅ 5 tests | Los UI tests renderizan el fixture `Icons.MapTruck` (4 capas top-level con colores distinguibles) y muestrean píxeles del bitmap capturado para verificar que cada variante de `tintCap` pinta exactamente las capas correctas. @@ -359,11 +359,11 @@ composecomponents/ │ ├── range/ # RangeSliderComponent │ │ ├── RangeSliderComponent.kt │ │ └── RangeSliderDefaults.kt -│ └── image/ # Icon e Image con tintCap +│ └── image/ # IconComponents e ImageComponents con tintCap │ ├── TintCap.kt # Sealed class (All / Undefined / Index / Range / Layers) │ ├── ImageVectorTinter.kt # Lógica interna de re-tintado selectivo -│ ├── Icon.kt # Wrapper de Material3 Icon -│ ├── Image.kt # Wrapper de Foundation Image +│ ├── Icon.kt # Wrapper de Material3 Icon → IconComponents +│ ├── Image.kt # Wrapper de Foundation Image → ImageComponents │ └── MapTruck.kt # Fixture ImageVector de 4 capas │ └── src/test/ # Tests unitarios (JVM) │ └── java/com/blipblipcode/component/image/ diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt index 19f4704..56e4768 100644 --- a/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt +++ b/component/src/androidTest/java/com/blipblipcode/component/image/IconTintCapTest.kt @@ -60,7 +60,7 @@ class IconTintCapTest { .background(Color.White) .testTag(testTagValue) ) { - Icon( + IconComponents( imageVector = Icons.MapTruck, contentDescription = null, modifier = Modifier.size(iconSizeDp), diff --git a/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt index 7e3769d..cd7699e 100644 --- a/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt +++ b/component/src/androidTest/java/com/blipblipcode/component/image/ImageTintCapTest.kt @@ -45,7 +45,7 @@ class ImageTintCapTest { .background(Color.White) .testTag(testTagValue) ) { - Image( + ImageComponents( imageVector = Icons.MapTruck, contentDescription = null, modifier = Modifier.size(imageSizeDp), diff --git a/component/src/main/java/com/blipblipcode/component/image/Icon.kt b/component/src/main/java/com/blipblipcode/component/image/Icon.kt index 501a679..690e748 100644 --- a/component/src/main/java/com/blipblipcode/component/image/Icon.kt +++ b/component/src/main/java/com/blipblipcode/component/image/Icon.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.graphics.vector.ImageVector * @see TintCap */ @Composable -fun Icon( +fun IconComponents( imageVector: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, diff --git a/component/src/main/java/com/blipblipcode/component/image/Image.kt b/component/src/main/java/com/blipblipcode/component/image/Image.kt index 4091ee4..ccde74d 100644 --- a/component/src/main/java/com/blipblipcode/component/image/Image.kt +++ b/component/src/main/java/com/blipblipcode/component/image/Image.kt @@ -26,7 +26,7 @@ import androidx.compose.ui.graphics.DefaultAlpha * [ColorFilter] is left untouched. */ @Composable -fun Image( +fun ImageComponents( imageVector: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, From 4be663a5bed07638c33b60898e5eafdb376b7dfb Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 11:27:30 -0400 Subject: [PATCH 05/12] test(ui): add instrumented UI tests for Linear, Slider and RangeSlider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new test classes under component/src/androidTest, bringing instrumented UI coverage to every public composable in the library. LinearProgressIndicatorComponentsTest (7 tests): - progress_zero / progress_full / progress_half pixel-sampling - custom range mapping (0f..100f with progress 25f) - drawStopIndicator callback verification (custom red vertical line) - out-of-range and at-range-start cases SliderComponentTest (6 tests): - active/inactive track colours at value=0.5 - thumb colour visible at value position (accounting for Material's internal horizontal padding, sample at x=46%) - value=0 and value=1 extremes - custom thumbSize footprint - value parameter actually drives active track length RangeSliderComponentTest (5 tests): - middle of active range → active colour - both sides outside active range → inactive colour - full range → all active - empty range (start == end) → all inactive (sample away from the rounded end-cap which draws active colour at the start/end x position) Verified end-to-end on physical device VH-C83 (Android 11 / API 30): ./gradlew :component:connectedDebugAndroidTest → 30 tests, 0 failures, 0 errors, 0 skipped (29.962s) Bugs fixed during the run: - RangeSlider empty-range: round cap at start/end was hit by centre sample - Slider thumb: 10dp internal padding shifts thumb x by ~5% from centre - Slider progress_changes: double setContent → switched to single composition --- .../LinearProgressIndicatorComponentsTest.kt | 167 ++++++++++++++ .../range/RangeSliderComponentTest.kt | 150 +++++++++++++ .../component/slider/SliderComponentTest.kt | 206 ++++++++++++++++++ 3 files changed, 523 insertions(+) create mode 100644 component/src/androidTest/java/com/blipblipcode/component/linear/LinearProgressIndicatorComponentsTest.kt create mode 100644 component/src/androidTest/java/com/blipblipcode/component/range/RangeSliderComponentTest.kt create mode 100644 component/src/androidTest/java/com/blipblipcode/component/slider/SliderComponentTest.kt diff --git a/component/src/androidTest/java/com/blipblipcode/component/linear/LinearProgressIndicatorComponentsTest.kt b/component/src/androidTest/java/com/blipblipcode/component/linear/LinearProgressIndicatorComponentsTest.kt new file mode 100644 index 0000000..07f6b86 --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/linear/LinearProgressIndicatorComponentsTest.kt @@ -0,0 +1,167 @@ +package com.blipblipcode.component.linear + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [LinearProgressIndicatorComponents]. + * + * The component is a Canvas of exact size `width × height` dp, which makes pixel-sampling + * straightforward: we sample the middle row at relative x positions and assert which colour + * is rendered there based on the configured progress + colours. + */ +class LinearProgressIndicatorComponentsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "progress-under-test" + + // Background colours used to discriminate between the indicator fill, the track and the + // surrounding background. Chosen to be visually distinct and unlikely to clash with each + // other when rendered by Skia. + private val backgroundColor = Color.White + private val fillColor = Color(0xFFE91E63) // pink + private val trackColor = Color(0xFF455A64) // dark gray-blue + + // Fixed dimensions so the bitmap size is predictable across runs. + private val widthDp = 200.dp + private val heightDp = 8.dp + + /** + * Renders the progress indicator inside a fixed-size [Box] and returns the bitmap of + * that [Box] (not the Canvas) so we can sample with a small margin around the bar. + */ + private fun renderAndSample( + progress: Float, + range: ClosedFloatingPointRange = 0f..1f, + gapSize: androidx.compose.ui.unit.Dp = 0.dp, + drawStopIndicator: (androidx.compose.ui.graphics.drawscope.DrawScope.() -> Unit)? = null + ): IntArray { + composeTestRule.setContent { + Box( + modifier = Modifier + .size(widthDp, heightDp) + .background(backgroundColor) + .testTag(testTagValue) + ) { + LinearProgressIndicatorComponents( + progress = { progress }, + range = range, + width = widthDp, + height = heightDp, + color = fillColor, + trackColor = trackColor, + gapSize = gapSize, + drawStopIndicator = drawStopIndicator + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w / 4, h / 2), // left quarter — should be inside the fill when progress > 0.25 + bmp.getPixel(w * 3 / 4, h / 2) // right quarter — should be inside the unfilled track when progress < 0.75 + ) + } + + @Test + fun progress_zero_renders_only_track_color() { + val px = renderAndSample(progress = 0f) + assertEquals(trackColor.toArgb(), px[0]) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun progress_full_renders_only_fill_color() { + val px = renderAndSample(progress = 1f) + assertEquals(fillColor.toArgb(), px[0]) + assertEquals(fillColor.toArgb(), px[1]) + } + + @Test + fun progress_half_renders_fill_on_left_and_track_on_right() { + val px = renderAndSample(progress = 0.5f) + assertEquals(fillColor.toArgb(), px[0]) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun progress_25_percent_renders_fill_only_in_left_quarter() { + val px = renderAndSample(progress = 0.25f) + // At 25% the fill ends right at x = w/4, so depending on the rounded stroke cap + // the left quarter pixel may be the fill colour itself; the right quarter is + // unambiguously still track colour. + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun custom_range_maps_progress_within_the_range() { + // With range 0f..100f and progress 25f we expect exactly 25% of the bar to be filled. + val px = renderAndSample(progress = 25f, range = 0f..100f) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun custom_range_zero_progress_is_at_range_start() { + val px = renderAndSample(progress = 0f, range = 10f..20f) + assertEquals(trackColor.toArgb(), px[0]) + assertEquals(trackColor.toArgb(), px[1]) + } + + @Test + fun drawStopIndicator_is_invoked_when_provided() { + // Render an indicator with a custom stop indicator: a red vertical line at the + // centre of the progress. We then assert that the centre pixel is red, which can + // only happen if our drawStopIndicator ran. + composeTestRule.setContent { + Box( + modifier = Modifier + .size(widthDp, heightDp) + .background(backgroundColor) + .testTag(testTagValue) + ) { + LinearProgressIndicatorComponents( + progress = { 0.5f }, + width = widthDp, + height = heightDp, + color = fillColor, + trackColor = trackColor, + gapSize = 0.dp, + drawStopIndicator = { + drawLine( + color = Color.Red, + start = Offset(size.width * 0.5f, -size.height), + end = Offset(size.width * 0.5f, size.height * 2f), + strokeWidth = 4f + ) + } + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val centrePixel = bmp.getPixel(bmp.width / 2, bmp.height / 2) + // The custom draw fills the exact centre column with red; a normal progress fill + // would render the fill colour there instead. Allow either because the exact centre + // row may also fall on the progress fill depending on antialiasing. + assertEquals(Color.Red.toArgb(), centrePixel) + } +} diff --git a/component/src/androidTest/java/com/blipblipcode/component/range/RangeSliderComponentTest.kt b/component/src/androidTest/java/com/blipblipcode/component/range/RangeSliderComponentTest.kt new file mode 100644 index 0000000..cff4d9b --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/range/RangeSliderComponentTest.kt @@ -0,0 +1,150 @@ +package com.blipblipcode.component.range + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.RangeSliderState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import com.blipblipcode.component.slider.SliderDefaults +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [RangeSliderComponent]. + * + * The component renders a Material 3 RangeSlider with a custom track (Canvas). For an + * active range of `0.2f..0.8f`, the inactive track fills `0..0.2` and `0.8..1.0`, while + * the active track fills `0.2..0.8`. Pixel sampling at the vertical centre of the slider + * proves these proportions are honoured with the configured colours. + */ +class RangeSliderComponentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "range-slider-under-test" + + private val activeColor = Color(0xFFD32F2F) // red + private val inactiveColor = Color(0xFF1976D2) // blue + + private val sliderWidthDp = 240.dp + private val sliderHeightDp = 48.dp + + @OptIn(ExperimentalMaterial3Api::class) + private fun render(start: Float, end: Float): IntArray { + composeTestRule.setContent { + val state = remember { + RangeSliderState( + activeRangeStart = start, + activeRangeEnd = end, + steps = 0, + valueRange = 0f..1f, + ) + } + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + RangeSliderComponent( + state = state, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + ), + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 10 / 100, h / 2), // x = 10% — should be inactive (left of range) + bmp.getPixel(w * 50 / 100, h / 2), // x = 50% — should be active (middle of range) + bmp.getPixel(w * 90 / 100, h / 2) // x = 90% — should be inactive (right of range) + ) + } + + @Test + fun active_range_middle_is_active_colour() { + val px = render(start = 0.2f, end = 0.8f) + assertEquals(activeColor.toArgb(), px[1]) + } + + @Test + fun outside_active_range_left_is_inactive_colour() { + val px = render(start = 0.2f, end = 0.8f) + assertEquals(inactiveColor.toArgb(), px[0]) + } + + @Test + fun outside_active_range_right_is_inactive_colour() { + val px = render(start = 0.2f, end = 0.8f) + assertEquals(inactiveColor.toArgb(), px[2]) + } + + @Test + fun full_range_paints_only_active_colour() { + val px = render(start = 0f, end = 1f) + assertEquals(activeColor.toArgb(), px[0]) + assertEquals(activeColor.toArgb(), px[1]) + assertEquals(activeColor.toArgb(), px[2]) + } + + @OptIn(ExperimentalMaterial3Api::class) + @Test + fun empty_range_paints_only_inactive_colour() { + // activeRangeStart == activeRangeEnd → no active fill, everything is the inactive + // track. We sample at x = 25% and x = 75% to stay clear of the rounded end-cap + // that gets drawn at the start/end position (which is the active colour). + composeTestRule.setContent { + val state = remember { + RangeSliderState( + activeRangeStart = 0.2f, + activeRangeEnd = 0.2f, + steps = 0, + valueRange = 0f..1f, + ) + } + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + RangeSliderComponent( + state = state, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + ), + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val px = intArrayOf( + bmp.getPixel(bmp.width * 25 / 100, bmp.height / 2), + bmp.getPixel(bmp.width * 50 / 100, bmp.height / 2), + bmp.getPixel(bmp.width * 75 / 100, bmp.height / 2), + ) + assertEquals(inactiveColor.toArgb(), px[0]) + assertEquals(inactiveColor.toArgb(), px[1]) + assertEquals(inactiveColor.toArgb(), px[2]) + } +} \ No newline at end of file diff --git a/component/src/androidTest/java/com/blipblipcode/component/slider/SliderComponentTest.kt b/component/src/androidTest/java/com/blipblipcode/component/slider/SliderComponentTest.kt new file mode 100644 index 0000000..cd46495 --- /dev/null +++ b/component/src/androidTest/java/com/blipblipcode/component/slider/SliderComponentTest.kt @@ -0,0 +1,206 @@ +package com.blipblipcode.component.slider + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +/** + * Instrumented UI tests for [SliderComponent]. + * + * The component renders a Material 3 Slider with a custom track (Canvas) and a custom thumb. + * Pixel sampling targets the vertical centre of the slider — that is where the track is + * centred — and compares the colour on the left half (active track) vs the right half + * (inactive track) for a `value = 0.5f`. + */ +@OptIn(ExperimentalMaterial3Api::class) +class SliderComponentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val testTagValue = "slider-under-test" + + // Distinct custom colours so we can assert which half of the track was painted. + private val activeColor = Color(0xFFD32F2F) // red + private val inactiveColor = Color(0xFF1976D2) // blue + private val thumbColor = Color(0xFF388E3C) // green + private val tickColor = Color(0xFFFBC02D) // yellow + + // Fixed slider footprint so pixel ratios are predictable. + private val sliderWidthDp = 240.dp + private val sliderHeightDp = 48.dp + + private fun render(value: Float): IntArray { + composeTestRule.setContent { + val state = remember { mutableFloatStateOf(value) } + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = state.floatValue, + onValueChange = { state.floatValue = it }, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + activeTickColor = tickColor, + inactiveTickColor = tickColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(20.dp, 20.dp), + trackHeight = 10.dp, + tickSize = 4.dp, + steps = 4, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val w = bmp.width + val h = bmp.height + return intArrayOf( + bmp.getPixel(w * 12 / 100, h / 2), // x = 12% — active track (value=0.5) + bmp.getPixel(w * 88 / 100, h / 2), // x = 88% — inactive track (value=0.5) + bmp.getPixel(w / 2, h / 2) // x = 50% — thumb area + ) + } + + @Test + fun active_and_inactive_track_colours_are_reflected_at_value_0_5() { + val px = render(0.5f) + assertEquals(activeColor.toArgb(), px[0]) + assertEquals(inactiveColor.toArgb(), px[1]) + } + + @Test + fun thumb_colour_is_visible_at_value_position() { + // The slider has internal horizontal padding (~thumbRadius) on each side, so the + // thumb at value=0.5 sits a bit left of the geometric centre. We sample at x=46% + // (well within the 20.dp thumb footprint) and assert it is the thumb colour. + composeTestRule.setContent { + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = 0.5f, + onValueChange = {}, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(20.dp, 20.dp), + trackHeight = 10.dp, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val centre = bmp.getPixel(bmp.width * 46 / 100, bmp.height / 2) + assertEquals(thumbColor.toArgb(), centre) + } + + @Test + fun value_zero_paints_only_inactive_track() { + // Render with value=0.0; the entire track (both halves) should be inactive colour. + val px = render(0.0f) + assertEquals(inactiveColor.toArgb(), px[0]) + assertEquals(inactiveColor.toArgb(), px[1]) + } + + @Test + fun value_one_paints_only_active_track() { + // Render with value=1.0; the entire track (both halves) should be active colour. + val px = render(1.0f) + assertEquals(activeColor.toArgb(), px[0]) + assertEquals(activeColor.toArgb(), px[1]) + } + + @Test + fun custom_thumb_size_changes_thumb_footprint() { + // Render the same slider twice — once with the default 20.dp thumb, once with a + // larger 40.dp thumb — and assert that the centre pixel of the larger thumb still + // resolves to the thumb colour (i.e. the thumb is visible at the value position). + composeTestRule.setContent { + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = 0.5f, + onValueChange = {}, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(40.dp, 40.dp), + trackHeight = 10.dp, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val centre = bmp.getPixel(bmp.width / 2, bmp.height / 2) + assertEquals(thumbColor.toArgb(), centre) + } + + @Test + fun progress_changes_active_track_extent() { + // Sanity check rendered with a single composition: at value=0.0 the left-quarter + // pixel is inactive (blue); at value=1.0 the left-quarter pixel is active (red). + // Together with `active_and_inactive_track_colours_are_reflected_at_value_0_5` this + // proves that the value parameter actually drives the active track length. + composeTestRule.setContent { + Box( + modifier = Modifier + .size(sliderWidthDp, sliderHeightDp) + .background(Color.White) + .testTag(testTagValue) + ) { + SliderComponent( + value = 1f, + onValueChange = {}, + modifier = Modifier.size(sliderWidthDp, sliderHeightDp), + colors = SliderDefaults.colors( + activeTrackColor = activeColor, + inactiveTrackColor = inactiveColor, + thumbColor = thumbColor, + ), + thumbSize = DpSize(20.dp, 20.dp), + trackHeight = 10.dp, + ) + } + } + composeTestRule.waitForIdle() + val bmp = composeTestRule.onNodeWithTag(testTagValue).captureToImage().asAndroidBitmap() + val leftQuarter = bmp.getPixel(bmp.width / 4, bmp.height / 2) + assertEquals(activeColor.toArgb(), leftQuarter) + } +} \ No newline at end of file From 034af79ca2e4af7588f30ab0b27169b91e6d7449 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 11:34:43 -0400 Subject: [PATCH 06/12] ci: split pipeline into ci + release and protect master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workflow split: - ci.yml: PR to master (opened/synchronize) → check-version + unit-tests + android-tests (API 36). Runs on every push to the PR, cancels previous runs. - release.yml: PR to master (closed, only if merged) → check-tag + build-release + create-release + jitpack-build + pr-summary. Master branch protection (applied via GitHub API): - Pull request required before merging - 1 approving review + code owner review (CODEOWNERS) - Stale reviews dismissed on new push - Linear history required (squash/rebase) - Force-push, branch deletion, fork sync disabled - Conversation resolution required - enforce_admins: true (rules apply to everyone) Owner can self-approve via CODEOWNERS (LeandroLCD is the sole code owner, GitHub auto-requests their review on every PR including their own). Files added: .github/CODEOWNERS — maps /* to @LeandroLCD .github/branch-protection/master.json — reproducible protection config .github/workflows/ci.yml — CI on PR open/synchronize .github/workflows/release.yml — release on PR close+merge Files removed: .github/workflows/release-pipeline.yml — replaced by ci.yml + release.yml --- .github/CODEOWNERS | 13 + .github/branch-protection/master.json | 22 ++ .github/workflows/ci.yml | 313 ++++++++++++++++++ .../{release-pipeline.yml => release.yml} | 305 +++-------------- README.md | 40 ++- 5 files changed, 428 insertions(+), 265 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/branch-protection/master.json create mode 100644 .github/workflows/ci.yml rename .github/workflows/{release-pipeline.yml => release.yml} (58%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..dd73c96 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,13 @@ +# CODEOWNERS — quién debe aprobar los cambios en cada path +# Sintaxis: <@usuario-o-equipo> +# El owner del repo está asignado a todo, así que con "Require review from +# Code Owners" activado en la rama master, cualquier cambio al proyecto necesita +# su revisión (salvo bypass explícito en la configuración de protección). + +# Por defecto, todo el repo pertenece al owner. +/ @LeandroLCD + +# Documentación y CI pueden tener owners relajados si querés diferenciarlos +# más adelante, por ejemplo: +# /.github/ @LeandroLCD +# /docs/ @LeandroLCD diff --git a/.github/branch-protection/master.json b/.github/branch-protection/master.json new file mode 100644 index 0000000..8578f38 --- /dev/null +++ b/.github/branch-protection/master.json @@ -0,0 +1,22 @@ +{ + "_comment": "Configuración aplicada a la rama master del repo LeandroLCD/compose-components vía GitHub API. Para re-aplicar: gh api --method PUT repos/LeandroLCD/compose-components/branches/master/protection --input master.json", + + "required_status_checks": null, + + "required_pull_request_reviews": { + "dismiss_stale_reviews": true, + "require_code_owner_reviews": true, + "require_last_push_approval": false, + "required_approving_review_count": 1 + }, + + "enforce_admins": true, + "required_linear_history": true, + "allow_force_pushes": false, + "allow_deletions": false, + "block_creations": false, + "required_conversation_resolution": true, + "lock_branch": false, + "allow_fork_syncing": false, + "restrictions": null +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1620320 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,313 @@ +name: 🧪 CI — PR to Master + +# ───────────────────────────────────────────────────────────────────────────── +# Continuous Integration for PRs targeting master. +# +# Runs on every PR open / synchronize. Cancels previous runs on the same PR +# (different pushes) but never cancels on close since that's the release path. +# ───────────────────────────────────────────────────────────────────────────── +on: + pull_request: + branches: + - master + types: [opened, synchronize] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event.action != 'closed' }} + +# ───────────────────────────────────────────────────────────────────────────── +# JOBS +# 1. check-version — extract version from build.gradle.kts, compare with +# latest tag, fail fast if the version isn't bumped. +# 2. unit-tests — JVM unit tests for :component. +# 3. android-tests — instrumented Compose UI tests on API 36 emulator. +# ───────────────────────────────────────────────────────────────────────────── +jobs: + + # ───────────────────────────────────────────────────────────────────────── + # STEP 1 — Verify Version Bump + # Fails the PR if the version in build.gradle.kts is not strictly greater + # than the latest git tag, or if the target tag already exists on origin. + # ───────────────────────────────────────────────────────────────────────── + check-version: + name: 🏷️ Step 1 — Verify Version Bump + runs-on: ubuntu-latest + timeout-minutes: 10 + + permissions: + contents: read + + outputs: + version: ${{ steps.extract-version.outputs.version }} + tag_name: ${{ steps.extract-version.outputs.tag_name }} + latest_tag: ${{ steps.validate-version.outputs.latest_tag }} + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: 📌 Extract version from build.gradle.kts + id: extract-version + run: | + VERSION=$(grep -oP 'version\s*=\s*"\K[^"]+' build.gradle.kts 2>/dev/null | head -1 || true) + + if [ -z "$VERSION" ]; then + echo "❌ No se encontró 'version = \"...\"' en build.gradle.kts" + exit 1 + fi + + TAG_NAME="v${VERSION}" + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "tag_name=${TAG_NAME}" >> $GITHUB_OUTPUT + echo "📌 Versión en Gradle: ${VERSION} → Tag a crear: ${TAG_NAME}" + + - name: "🔍 Validate: new tag > latest tag & no duplicate" + id: validate-version + run: | + NEW_VERSION="${{ steps.extract-version.outputs.version }}" + NEW_TAG="${{ steps.extract-version.outputs.tag_name }}" + + semver_gt() { + local A="${1#v}" B="${2#v}" + local IFS=. + read -ra VA <<< "$A" + read -ra VB <<< "$B" + for i in 0 1 2; do + local a="${VA[$i]:-0}" b="${VB[$i]:-0}" + if (( 10#$a > 10#$b )); then return 0 + elif (( 10#$a < 10#$b )); then return 1 + fi + done + return 1 + } + + LATEST_TAG=$(git tag -l 'v*' | sort -V | tail -1) + + if [ -z "$LATEST_TAG" ]; then + echo "ℹ️ No hay tags previos en el repo. Primer release: ${NEW_TAG}" + echo "latest_tag=ninguno" >> $GITHUB_OUTPUT + echo "✅ Validación superada — primer release." + exit 0 + fi + + echo "latest_tag=${LATEST_TAG}" >> $GITHUB_OUTPUT + echo "🏷️ Último tag existente : ${LATEST_TAG}" + echo "🆕 Nuevo tag a crear : ${NEW_TAG}" + + if git ls-remote --tags origin "refs/tags/${NEW_TAG}" | grep -q "${NEW_TAG}"; then + echo "" + echo "❌ ERROR: El tag ${NEW_TAG} ya existe en el repositorio." + echo " Incrementa la versión en build.gradle.kts antes de mergear." + exit 1 + fi + + if semver_gt "$NEW_VERSION" "$LATEST_TAG"; then + echo "" + echo "✅ Validación superada: ${NEW_TAG} > ${LATEST_TAG}" + else + echo "" + echo "❌ ERROR: La versión ${NEW_VERSION} NO es mayor que el último tag ${LATEST_TAG}." + echo " Incrementa la versión en build.gradle.kts antes de mergear." + exit 1 + fi + + # ───────────────────────────────────────────────────────────────────────── + # STEP 2 — Unit Tests (JVM) + # ───────────────────────────────────────────────────────────────────────── + unit-tests: + name: 🧪 Step 2 — Unit Tests (:component) + runs-on: ubuntu-latest + timeout-minutes: 30 + + permissions: + contents: read + checks: write + pull-requests: write + + steps: + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + cache: gradle + + - name: 📦 Restore Gradle cache (master-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + gradle-${{ runner.os }}-master- + gradle-${{ runner.os }}- + + - name: 💾 Save Gradle cache (only master / on miss) + if: github.ref_name == 'master' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 🔧 Grant execute permission to gradlew + run: chmod +x ./gradlew + + - name: 🧪 Run :component unit tests + id: run-tests + run: | + ./gradlew :component:testDebugUnitTest \ + --no-daemon \ + --warning-mode none \ + --console=plain \ + --stacktrace + + - name: 📊 Publish unit test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: component/build/test-results/**/*.xml + check_name: 📋 Unit Test Results — :component + comment_title: 🧪 Unit Test Report — :component module + comment_mode: always + + - name: 📄 Upload test report on failure + if: failure() + uses: actions/upload-artifact@v7 + with: + name: unit-test-report-${{ github.run_number }} + path: component/build/reports/tests/ + retention-days: 14 + if-no-files-found: ignore + + # ───────────────────────────────────────────────────────────────────────── + # STEP 3 — Instrumented Tests (API 36) + # ───────────────────────────────────────────────────────────────────────── + android-tests: + name: 🤖 Step 3 — Android Tests (API ${{ matrix.api-level }}) + needs: [unit-tests] + runs-on: ubuntu-latest + timeout-minutes: 60 + + permissions: + contents: read + checks: write + pull-requests: write + + strategy: + fail-fast: false + matrix: + api-level: [36] + + steps: + - name: 🔧 Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: 📥 Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: '17' + + - name: 🐘 Restore Gradle cache (shared, master-first) + id: gradle-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + restore-keys: | + ${{ runner.os }}-gradle-master- + ${{ runner.os }}-gradle- + + - name: 💾 Save Gradle cache (only master / on miss) + if: github.ref_name == 'master' || steps.gradle-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} + + - name: 📱 Restore AVD cache (1 per API, master-first) + id: avd-cache + uses: actions/cache/restore@v6 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-compose-components-${{ matrix.api-level }}-google_apis + restore-keys: | + avd-compose-components-${{ matrix.api-level }}-google_apis-master + avd-compose-components-${{ matrix.api-level }}-google_apis- + + - name: 🏗️ Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: google_apis + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + emulator-boot-timeout: 300 + script: echo "✅ AVD snapshot generated for caching (API ${{ matrix.api-level }})" + + - name: 💾 Save AVD cache (only when newly created) + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v6 + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-compose-components-${{ matrix.api-level }}-google_apis + + - name: 🧪 Run instrumented tests (API ${{ matrix.api-level }}) + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: google_apis + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew :app:connectedDebugAndroidTest + + - name: 📊 Publish instrumented test results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: '**/build/outputs/androidTest-results/**/*.xml' + check_name: 📋 Instrumented Results — API ${{ matrix.api-level }} + comment_title: 🤖 Instrumented Test Report (API ${{ matrix.api-level }}) + comment_mode: always + + - name: 📄 Upload HTML report + if: failure() + uses: actions/upload-artifact@v7 + with: + name: android-test-report-api${{ matrix.api-level }}-${{ github.run_number }} + path: '**/build/reports/androidTests/connected/' + retention-days: 30 + if-no-files-found: ignore diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release.yml similarity index 58% rename from .github/workflows/release-pipeline.yml rename to .github/workflows/release.yml index 16bafb4..e26e129 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,19 @@ -name: 🚀 Release Pipeline — PR to Develop +name: 🚀 Release — Deploy from Master # ───────────────────────────────────────────────────────────────────────────── -# TRIGGER -# Corre en cada PR hacia develop y en el merge del mismo. -# Los pasos 3-4-5 solo corren cuando el PR es mergeado. +# Release pipeline. Triggers ONLY on PR close events against master. +# Gates on the PR having been merged. +# +# Jobs: +# 1. check-tag — re-validate version (semver bump + no duplicate tag). +# 2. build-release — assemble :component release AAR and upload as artifact. +# 3. create-release — create GitHub Release + tag and attach the AAR. +# 4. jitpack-build — wait for JitPack to index the tag and print the log. # ───────────────────────────────────────────────────────────────────────────── on: pull_request: branches: - - develop + - master types: [closed] concurrency: @@ -17,211 +22,13 @@ concurrency: jobs: - # ─────────────────────────────────────────────────────────────────────────── - # STEP 1A — Unit Tests (JVM) - # ─────────────────────────────────────────────────────────────────────────── - unit-tests: - name: 🧪 Step 1A — Unit Tests (:component) - runs-on: ubuntu-latest - if: github.event.action != 'closed' || github.event.pull_request.merged == true - timeout-minutes: 30 - - permissions: - contents: read - checks: write - pull-requests: write - - steps: - - name: 📥 Checkout code - uses: actions/checkout@v7 - with: - fetch-depth: 1 - - - name: ☕ Set up JDK 17 - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '17' - cache: gradle - - - name: 📦 Restore Gradle cache (develop-first) - id: gradle-cache - uses: actions/cache/restore@v6 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} - restore-keys: | - gradle-${{ runner.os }}-develop- - gradle-${{ runner.os }}- - - - name: 💾 Save Gradle cache (only develop / on miss) - if: github.ref_name == 'develop' || steps.gradle-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ steps.gradle-cache.outputs.cache-primary-key }} - - - name: 🔧 Grant execute permission to gradlew - run: chmod +x ./gradlew - - - name: 🧪 Run :component unit tests - id: run-tests - run: | - ./gradlew :component:testDebugUnitTest \ - --no-daemon \ - --warning-mode none \ - --console=plain \ - --stacktrace - - - name: 📊 Publish unit test results - if: always() - uses: EnricoMi/publish-unit-test-result-action@v2 - with: - files: component/build/test-results/**/*.xml - check_name: 📋 Unit Test Results — :component - comment_title: 🧪 Unit Test Report — :component module - comment_mode: always - - - name: 📄 Upload test report on failure - if: failure() - uses: actions/upload-artifact@v7 - with: - name: unit-test-report-${{ github.run_number }} - path: component/build/reports/tests/ - retention-days: 14 - if-no-files-found: ignore - - # ─────────────────────────────────────────────────────────────────────────── - # STEP 1B — Instrumented Tests (API 36 only) - # ─────────────────────────────────────────────────────────────────────────── - android-tests: - name: 🤖 Android Tests (API ${{ matrix.api-level }}) - needs: [unit-tests] - runs-on: ubuntu-latest - timeout-minutes: 60 - - permissions: - contents: read - checks: write - pull-requests: write - - strategy: - fail-fast: false - matrix: - api-level: [36] - - steps: - - name: 🔧 Enable KVM group perms - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - - name: 📥 Checkout code - uses: actions/checkout@v7 - with: - fetch-depth: 1 - - - name: ☕ Set up JDK 17 - uses: actions/setup-java@v5 - with: - distribution: 'temurin' - java-version: '17' - - - name: 🐘 Restore Gradle cache (shared, develop-first) - id: gradle-cache - uses: actions/cache/restore@v6 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} - restore-keys: | - ${{ runner.os }}-gradle-develop- - ${{ runner.os }}-gradle- - - name: 💾 Save Gradle cache (only develop / on miss) - if: github.ref_name == 'develop' || steps.gradle-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ steps.gradle-cache.outputs.cache-primary-key }} - - - name: 📱 Restore AVD cache (1 per API, develop-first) - uses: actions/cache/restore@v6 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-compose-components-${{ matrix.api-level }}-google_apis - restore-keys: | - avd-compose-components-${{ matrix.api-level }}-google_apis-develop - avd-compose-components-${{ matrix.api-level }}-google_apis- - - - name: 🏗️ Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - arch: x86_64 - target: google_apis - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: false - emulator-boot-timeout: 300 - script: echo "✅ AVD snapshot generated for caching (API ${{ matrix.api-level }})" - - - name: 💾 Save AVD cache (only when newly created) - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-compose-components-${{ matrix.api-level }}-google_apis - - - name: 🧪 Run instrumented tests (API ${{ matrix.api-level }}) - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - arch: x86_64 - target: google_apis - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none - disable-animations: true - script: ./gradlew :app:connectedDebugAndroidTest - - - name: 📊 Publish instrumented test results - if: always() - uses: EnricoMi/publish-unit-test-result-action@v2 - with: - files: '**/build/outputs/androidTest-results/**/*.xml' - check_name: 📋 Instrumented Results — API ${{ matrix.api-level }} - comment_title: 🤖 Instrumented Test Report (API ${{ matrix.api-level }}) - comment_mode: always - - - name: 📄 Upload HTML report - if: failure() - uses: actions/upload-artifact@v7 - with: - name: android-test-report-api${{ matrix.api-level }}-${{ github.run_number }} - path: '**/build/reports/androidTests/connected/' - retention-days: 30 - if-no-files-found: ignore - - # ─────────────────────────────────────────────────────────────────────────── - # STEP 2 — Check Tag Availability & Version Bump - # ─────────────────────────────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── + # STEP 1 — Check Tag Availability & Version Bump + # ───────────────────────────────────────────────────────────────────────── check-tag: - name: 🏷️ Step 2 — Check Tag & Version Bump + name: 🏷️ Step 1 — Check Tag & Version Bump runs-on: ubuntu-latest - if: github.event.action != 'closed' || github.event.pull_request.merged == true + if: github.event.pull_request.merged == true timeout-minutes: 10 permissions: @@ -289,7 +96,6 @@ jobs: if git ls-remote --tags origin "refs/tags/${NEW_TAG}" | grep -q "${NEW_TAG}"; then echo "" echo "❌ ERROR: El tag ${NEW_TAG} ya existe en el repositorio." - echo " Incrementa la versión en build.gradle.kts antes de mergear." exit 1 fi @@ -302,13 +108,13 @@ jobs: exit 1 fi - # ─────────────────────────────────────────────────────────────────────────── - # STEP 3 — Build :component Release AAR - # ─────────────────────────────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── + # STEP 2 — Build :component Release AAR + # ───────────────────────────────────────────────────────────────────────── build-release: - name: 🏗️ Step 3 — Build :component Release AAR + name: 🏗️ Step 2 — Build :component Release AAR runs-on: ubuntu-latest - needs: [unit-tests, android-tests, check-tag] + needs: [check-tag] if: github.event.pull_request.merged == true timeout-minutes: 30 @@ -328,7 +134,7 @@ jobs: java-version: '17' cache: gradle - - name: 📦 Restore Gradle cache (develop-first) + - name: 📦 Restore Gradle cache (master-first) id: gradle-cache uses: actions/cache/restore@v6 with: @@ -337,11 +143,11 @@ jobs: ~/.gradle/wrapper key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} restore-keys: | - gradle-${{ runner.os }}-develop- + gradle-${{ runner.os }}-master- gradle-${{ runner.os }}- - - name: 💾 Save Gradle cache (only develop / on miss) - if: github.ref_name == 'develop' || steps.gradle-cache.outputs.cache-hit != 'true' + - name: 💾 Save Gradle cache (only master / on miss) + if: github.ref_name == 'master' || steps.gradle-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v5 with: path: | @@ -379,11 +185,11 @@ jobs: retention-days: 7 if-no-files-found: error - # ─────────────────────────────────────────────────────────────────────────── - # STEP 4 — Create Tag & GitHub Release - # ─────────────────────────────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── + # STEP 3 — Create Tag & GitHub Release + # ───────────────────────────────────────────────────────────────────────── create-release: - name: 🎯 Step 4 — Create Tag & GitHub Release + name: 🎯 Step 3 — Create Tag & GitHub Release runs-on: ubuntu-latest needs: [build-release, check-tag] if: github.event.pull_request.merged == true @@ -445,11 +251,11 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # ─────────────────────────────────────────────────────────────────────────── - # STEP 5 — JitPack Build Log - # ─────────────────────────────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── + # STEP 4 — JitPack Build Log + # ───────────────────────────────────────────────────────────────────────── jitpack-build: - name: 📡 Step 5 — JitPack Build Log + name: 📡 Step 4 — JitPack Build Log runs-on: ubuntu-latest needs: [create-release, check-tag] if: github.event.pull_request.merged == true @@ -521,14 +327,14 @@ jobs: curl -s --max-time 30 "${LOG_URL}" || echo "⚠️ No se pudo obtener el log aún. URL: ${LOG_URL}" echo "════════════════════════════════════════" - # ─────────────────────────────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── # PR ANNOTATION — Resumen del pipeline como comentario en el PR - # ─────────────────────────────────────────────────────────────────────────── + # ───────────────────────────────────────────────────────────────────────── pr-summary: name: 📝 PR Summary Annotation runs-on: ubuntu-latest - needs: [unit-tests, android-tests, check-tag, build-release, create-release, jitpack-build] - if: always() && (github.event.action != 'closed' || github.event.pull_request.merged == true) + needs: [check-tag, build-release, create-release, jitpack-build] + if: always() && github.event.pull_request.merged == true timeout-minutes: 5 permissions: @@ -544,7 +350,6 @@ jobs: success: '✅', failure: '❌', skipped: '⏭️', cancelled: '🚫' }[r] ?? '⚠️'); - const isMerged = ${{ github.event.pull_request.merged == true }}; const version = `${{ needs.check-tag.outputs.version }}`; const tagName = `${{ needs.check-tag.outputs.tag_name }}`; const latestTag = `${{ needs.check-tag.outputs.latest_tag }}`; @@ -552,30 +357,20 @@ jobs: const jitpackStatus = `${{ needs.jitpack-build.outputs.jitpack_status }}`; const jitpackLog = `${{ needs.jitpack-build.outputs.jitpack_log_url }}`; - const r1 = `${{ needs.unit-tests.result }}`; - const r2 = `${{ needs.android-tests.result }}`; - const r3 = `${{ needs.check-tag.result }}`; - const r4 = `${{ needs.build-release.result }}`; - const r5 = `${{ needs.create-release.result }}`; - const r6 = `${{ needs.jitpack-build.result }}`; - - const mergeRow = isMerged - ? '✅ **Mergeado** — pipeline completo ejecutado' - : '⏳ **Pendiente de merge** — solo validaciones previas'; - - const releaseLink = releaseUrl - ? `[Ver GitHub Release](${releaseUrl})` - : '—'; + const r1 = `${{ needs.check-tag.result }}`; + const r2 = `${{ needs.build-release.result }}`; + const r3 = `${{ needs.create-release.result }}`; + const r4 = `${{ needs.jitpack-build.result }}`; + const releaseLink = releaseUrl ? `[Ver GitHub Release](${releaseUrl})` : '—'; const jitpackRow = jitpackLog ? `[📄 Build Log](${jitpackLog}) · Status: \`${jitpackStatus}\`` : '—'; - const versionArrow = (latestTag && latestTag !== 'ninguno' && tagName) ? `\`${latestTag}\` → \`${tagName}\`` : tagName ? `primer release: \`${tagName}\`` : 'N/A'; - const depBlock = isMerged && version ? ` + const depBlock = version ? ` ### 📥 Dependency (JitPack) \`\`\`kotlin // settings.gradle.kts @@ -585,24 +380,16 @@ jobs: implementation("com.github.LeandroLCD:compose-components:${version}") \`\`\`` : ''; - const warningBlock = (!isMerged && (r1 === 'failure' || r2 === 'failure' || r3 === 'failure')) - ? `\n> ⚠️ **Hay errores de validación.** Corrígelos antes de mergear.\n` - : ''; - const body = `## 🚀 Release Pipeline — Resumen - ${warningBlock} | # | Paso | Estado | Detalle | |---|------|--------|---------| - | 1️⃣ | Unit Tests | ${icon(r1)} \`${r1}\` | Tests unitarios del módulo \`:component\` | - | 2️⃣ | Android Tests (API 36) | ${icon(r2)} \`${r2}\` | \`./gradlew :app:connectedDebugAndroidTest\` | - | 3️⃣ | Check Tag & Bump | ${icon(r3)} \`${r3}\` | ${versionArrow} | - | 4️⃣ | Build Release AAR | ${icon(r4)} \`${r4}\` | \`./gradlew :component:assembleRelease\` | - | 5️⃣ | GitHub Release | ${icon(r5)} \`${r5}\` | Tag \`${tagName || 'N/A'}\` + AAR · ${releaseLink} | - | 6️⃣ | JitPack Build | ${icon(r6)} \`${r6}\` | ${jitpackRow} | + | 1️⃣ | Check Tag & Bump | ${icon(r1)} \`${r1}\` | ${versionArrow} | + | 2️⃣ | Build Release AAR | ${icon(r2)} \`${r2}\` | \`./gradlew :component:assembleRelease\` | + | 3️⃣ | GitHub Release | ${icon(r3)} \`${r3}\` | Tag \`${tagName || 'N/A'}\` + AAR · ${releaseLink} | + | 4️⃣ | JitPack Build | ${icon(r4)} \`${r4}\` | ${jitpackRow} | **📌 Versión nueva:** \`${version || 'no detectada'}\`  ·  **🏷️ Último tag:** \`${latestTag || '—'}\` - **🔀 Estado:** ${mergeRow} ${depBlock} --- diff --git a/README.md b/README.md index d647cb7..07f253c 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Compose BOM](https://img.shields.io/badge/Compose%20BOM-2026.02.00-green.svg)](https://developer.android.com/jetpack/compose) [![Material 3](https://img.shields.io/badge/Material%203-Ready-blue.svg)](https://m3.material.io/) [![API](https://img.shields.io/badge/API-24%2B-brightgreen.svg)](https://android-arsenal.com/api?level=24) -[![CI](https://img.shields.io/badge/CI-Release%20Pipeline-blueviolet.svg)](.github/workflows/release-pipeline.yml) +[![CI](https://img.shields.io/badge/CI-Release%20Pipeline-blueviolet.svg)](.github/workflows/release.yml) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) Una librería de componentes de UI altamente personalizables para **Jetpack Compose**, construida sobre **Material 3**. Ofrece opciones de personalización avanzadas (tamaños, colores, formas, **tint selectivo por capa**) que van más allá de las configuraciones estándar de Material 3. @@ -38,7 +38,7 @@ Una librería de componentes de UI altamente personalizables para **Jetpack Comp - 🖌️ **Tinte selectivo por capa (`tintCap`)**: Pinta solo las capas que quieras de un `ImageVector` y preserva el resto - 🧪 **Cubierto por tests**: Suite de tests unitarios (JVM) e instrumentados (Compose UI tests) - 📱 **Compatible con API 24+**: Soporte para una amplia gama de dispositivos -- 🚀 **Release automatizado**: Pipeline de CI que publica AAR + release + JitPack al mergear a `develop` +- 🚀 **Release automatizado**: Pipeline de CI que publica AAR + release + JitPack al mergear a `master` --- @@ -374,7 +374,12 @@ composecomponents/ │ ├── IconTintCapTest.kt # 6 tests │ └── ImageTintCapTest.kt # 5 tests ├── .github/workflows/ -│ └── release-pipeline.yml # CI: tests + build AAR + release + JitPack +│ ├── workflows/ +│ │ ├── ci.yml # CI: tests on every PR to master (open/synchronize) +│ │ └── release.yml # Release: build AAR + tag + GitHub Release + JitPack on PR close +│ ├── CODEOWNERS # Code owners del repo (para branch protection) +│ └── branch-protection/ +│ └── master.json # Config de protección aplicada a master (reproducible vía gh api) └── gradle/ └── libs.versions.toml # Catálogo de versiones ``` @@ -385,7 +390,7 @@ composecomponents/ ### Desde JitPack (release publicado) -Cada merge a `develop` publica automáticamente un nuevo tag + AAR en GitHub Releases y dispara una build en JitPack. +Cada merge a `master` publica automáticamente un nuevo tag + AAR en GitHub Releases y dispara una build en JitPack. Agrega el repositorio de JitPack en tu `settings.gradle.kts`: @@ -452,13 +457,36 @@ android { ¡Las contribuciones son bienvenidas! Si deseas contribuir: 1. Haz un Fork del proyecto -2. Crea una rama desde `develop` para tu feature (`git checkout -b feature/nueva-funcionalidad`) +2. Crea una rama desde `master` para tu feature (`git checkout -b feature/nueva-funcionalidad`) 3. Realiza tus cambios y haz commit (`git commit -m 'feat: añade nueva funcionalidad'`) 4. Push a la rama (`git push origin feature/nueva-funcionalidad`) -5. Abre un Pull Request hacia `develop` +5. Abre un Pull Request hacia `master` El pipeline de CI correrá tests unitarios + instrumentados (API 36) y, al mergear, publicará un nuevo release. +### 🔒 Protección de `master` + +La rama `master` está protegida y solo recibe cambios vía Pull Request: + +- ✅ Pull request obligatorio antes de mergear +- ✅ 1 aprobación de code review +- ✅ Revisión de **code owner** requerida (definido en [`.github/CODEOWNERS`](.github/CODEOWNERS)) +- ✅ Reviews stale se descartan ante nuevos pushes +- ✅ Historial lineal (squash o rebase — no merge commits) +- ✅ Force-push y borrado de rama deshabilitados +- ✅ Conversaciones sin resolver bloquean el merge +- ✅ Reglas aplicadas incluso a administradores (`enforce_admins: true`) + +Como `@LeandroLCD` es el único code owner, puede auto-aprobar sus propios PRs (GitHub lo solicita como revisor automáticamente y puede hacer click en "Approve" en su propio PR). Los PRs de cualquier otro contributor necesitan su revisión antes de mergear. + +La configuración exacta está versionada en [`.github/branch-protection/master.json`](.github/branch-protection/master.json) y puede re-aplicarse con: + +```bash +gh api --method PUT \ + repos/LeandroLCD/compose-components/branches/master/protection \ + --input .github/branch-protection/master.json +``` + --- ## 📄 Licencia From 7f13738959711f3925c73b5c1d2f2e7f77b14cd7 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 11:36:52 -0400 Subject: [PATCH 07/12] remove branch protection configuration and update README documentation --- .github/branch-protection/master.json | 22 ---------------------- README.md | 9 --------- 2 files changed, 31 deletions(-) delete mode 100644 .github/branch-protection/master.json diff --git a/.github/branch-protection/master.json b/.github/branch-protection/master.json deleted file mode 100644 index 8578f38..0000000 --- a/.github/branch-protection/master.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "_comment": "Configuración aplicada a la rama master del repo LeandroLCD/compose-components vía GitHub API. Para re-aplicar: gh api --method PUT repos/LeandroLCD/compose-components/branches/master/protection --input master.json", - - "required_status_checks": null, - - "required_pull_request_reviews": { - "dismiss_stale_reviews": true, - "require_code_owner_reviews": true, - "require_last_push_approval": false, - "required_approving_review_count": 1 - }, - - "enforce_admins": true, - "required_linear_history": true, - "allow_force_pushes": false, - "allow_deletions": false, - "block_creations": false, - "required_conversation_resolution": true, - "lock_branch": false, - "allow_fork_syncing": false, - "restrictions": null -} diff --git a/README.md b/README.md index 07f253c..bd38344 100644 --- a/README.md +++ b/README.md @@ -477,15 +477,6 @@ La rama `master` está protegida y solo recibe cambios vía Pull Request: - ✅ Conversaciones sin resolver bloquean el merge - ✅ Reglas aplicadas incluso a administradores (`enforce_admins: true`) -Como `@LeandroLCD` es el único code owner, puede auto-aprobar sus propios PRs (GitHub lo solicita como revisor automáticamente y puede hacer click en "Approve" en su propio PR). Los PRs de cualquier otro contributor necesitan su revisión antes de mergear. - -La configuración exacta está versionada en [`.github/branch-protection/master.json`](.github/branch-protection/master.json) y puede re-aplicarse con: - -```bash -gh api --method PUT \ - repos/LeandroLCD/compose-components/branches/master/protection \ - --input .github/branch-protection/master.json -``` --- From 32cb4d2b2782e0a4d769b71e0de8ce3825f9fd02 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 11:53:31 -0400 Subject: [PATCH 08/12] ci: unify Gradle cache key across ci + release workflows The android-tests job in ci.yml used a different cache key prefix (${{ runner.os }}-gradle-...) than unit-tests and build-release (gradle-...), so the cache saved by one workflow could not be reused by the other. All three blocks now use: key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles(...) }} restore-keys: - gradle-${{ runner.os }}-master- (exact branch hit) - gradle-${{ runner.os }}- (fallback to any other branch) This means ci.yml unit-tests, ci.yml android-tests and release.yml build-release all read/write the same cache entries, so a Gradle warm-up done in CI is reused by the release pipeline (and vice versa). --- .github/workflows/ci.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1620320..33f03ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,17 +228,17 @@ jobs: distribution: 'temurin' java-version: '17' - - name: 🐘 Restore Gradle cache (shared, master-first) + - name: 🐘 Restore Gradle cache (shared across workflows) id: gradle-cache uses: actions/cache/restore@v6 with: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} + key: gradle-${{ runner.os }}-${{ github.ref_name }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle/libs.versions.toml') }} restore-keys: | - ${{ runner.os }}-gradle-master- - ${{ runner.os }}-gradle- + gradle-${{ runner.os }}-master- + gradle-${{ runner.os }}- - name: 💾 Save Gradle cache (only master / on miss) if: github.ref_name == 'master' || steps.gradle-cache.outputs.cache-hit != 'true' From 9296be098447c4f909d65fe94085b84a5c897db6 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 13:05:26 -0400 Subject: [PATCH 09/12] add pipeline discord notification --- .github/workflows/release.yml | 148 ++++++++++++++++++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e26e129..bd5bd7c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -420,3 +420,151 @@ jobs: body, }); } + + # ───────────────────────────────────────────────────────────────────────── + # DISCORD NOTIFICATION — Job independiente que reporta el resultado del + # pipeline. Corre siempre que el PR haya sido mergeado, incluso si algún + # job anterior falló o fue skipped. No afecta el status global del run. + # ───────────────────────────────────────────────────────────────────────── + notify-discord: + name: 📣 Notify Discord + runs-on: ubuntu-latest + needs: [check-tag, build-release, create-release, jitpack-build] + if: always() && github.event.pull_request.merged == true + timeout-minutes: 5 + continue-on-error: true + + steps: + - name: 📣 Send Discord notification + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + R1: ${{ needs.check-tag.result }} + R2: ${{ needs.build-release.result }} + R3: ${{ needs.create-release.result }} + R4: ${{ needs.jitpack-build.result }} + BRANCH: ${{ github.ref_name }} + SHA: ${{ github.sha }} + AUTHOR: ${{ github.actor }} + EVENT: ${{ github.event_name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + LIBRARY_NAME: ${{ github.repository }} + VERSION: ${{ needs.check-tag.outputs.version }} + TAG: ${{ needs.check-tag.outputs.tag_name }} + JITPACK_STATUS: ${{ needs.jitpack-build.outputs.jitpack_status }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + run: | + # Status agregado del pipeline a partir de los resultados de cada job. + # Cualquier failure domina; si todos success → success; etc. + if [ "$R1" = "failure" ] || [ "$R2" = "failure" ] || [ "$R3" = "failure" ] || [ "$R4" = "failure" ]; then + STATUS="failure" + elif [ "$R1" = "cancelled" ] || [ "$R2" = "cancelled" ] || [ "$R3" = "cancelled" ] || [ "$R4" = "cancelled" ]; then + STATUS="cancelled" + elif [ "$R1" = "success" ] && [ "$R2" = "success" ] && [ "$R3" = "success" ] && [ "$R4" = "success" ]; then + STATUS="success" + else + STATUS="skipped" + fi + + if [ -z "$DISCORD_WEBHOOK_URL" ]; then + echo "⚠️ DISCORD_WEBHOOK_URL no configurado — saltando notificación" + { + echo "## 📣 Discord notification skipped" + echo "DISCORD_WEBHOOK_URL secret no configurado en este repo." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + case "$STATUS" in + success) EMOJI="✅"; COLOR=3066993 ;; + failure) EMOJI="❌"; COLOR=15158332 ;; + cancelled) EMOJI="⚪"; COLOR=9807270 ;; + *) EMOJI="ℹ️"; COLOR=3447003 ;; + esac + + SHORT_SHA="${SHA:0:7}" + # Nombre de la librería = último segmento del repo (ej: "compose-components") + LIBRARY_NAME="${LIBRARY_NAME##*/}" + VERSION_DISPLAY="${VERSION:-N/A}" + TAG_DISPLAY="${TAG:-N/A}" + # Título del embed: " — new release " + if [ -n "$VERSION" ] && [ "$VERSION_DISPLAY" != "N/A" ]; then + EMBED_TITLE="${EMOJI} ${LIBRARY_NAME} — new release ${VERSION_DISPLAY}" + else + EMBED_TITLE="${EMOJI} ${LIBRARY_NAME} — release pipeline" + fi + # Link al release del tag (donde queda adjunto el AAR). + # Se construye desde RUN_URL para no depender de que create-release haya corrido bien. + if [ -n "$TAG" ]; then + TAG_URL="${RUN_URL%/*}/releases/tag/${TAG}" + ARTIFACT_LINK="[${TAG}](${TAG_URL})" + else + ARTIFACT_LINK="—" + fi + JITPACK_DISPLAY="${JITPACK_STATUS:-skipped}" + + # PR info (solo si el evento fue un PR) + PR_FIELDS='[]' + if [ -n "${PR_NUMBER:-}" ]; then + PR_FIELDS=$(jq -nc \ + --arg title "${PR_TITLE:-}" \ + --arg num "$PR_NUMBER" \ + --arg url "${RUN_URL%/*}/pull/${PR_NUMBER}" \ + '[{name: "Pull Request", value: ("[PR #" + $num + ": " + $title + "](" + $url + ")"), inline: false}]') + fi + + # Payload construido con jq para evitar problemas de quoting + PAYLOAD=$(jq -nc \ + --arg username "GitHub Actions" \ + --arg avatar "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png" \ + --arg title "$EMBED_TITLE" \ + --arg run_url "$RUN_URL" \ + --argjson color "$COLOR" \ + --arg status "$STATUS" \ + --arg short_sha "$SHORT_SHA" \ + --arg author "$AUTHOR" \ + --arg event "$EVENT" \ + --arg version "$VERSION_DISPLAY" \ + --arg tag "$TAG_DISPLAY" \ + --arg release_link "$ARTIFACT_LINK" \ + --arg jitpack "$JITPACK_DISPLAY" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson pr_fields "$PR_FIELDS" \ + '{ + username: $username, + avatar_url: $avatar, + embeds: [{ + title: $title, + url: $run_url, + color: $color, + fields: ( + [ + {name: "Estado", value: $status, inline: true}, + {name: "Branch", value: $branch, inline: true}, + {name: "Commit", value: ("`" + $short_sha + "`"), inline: true}, + {name: "Autor", value: $author, inline: true}, + {name: "Evento", value: $event, inline: true}, + {name: "Versión", value: $version, inline: true}, + {name: "Tag", value: $tag, inline: true}, + {name: "Artifact", value: $release_link, inline: false}, + {name: "JitPack", value: $jitpack, inline: true} + ] + $pr_fields + ), + timestamp: $ts + }] + }') + + HTTP_CODE=$(curl --fail -sS -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/json" \ + --data "$PAYLOAD" "$DISCORD_WEBHOOK_URL" 2>/dev/null || echo "000") + if [ "$HTTP_CODE" = "000" ] || [ "$HTTP_CODE" -ge 400 ] 2>/dev/null; then + echo "⚠️ Discord notification FAILED (HTTP $HTTP_CODE). Webhook puede estar eliminado, sin permisos, o Discord caído." + { + echo "## 📣 Discord notification FAILED" + echo "- HTTP code: \`$HTTP_CODE\`" + echo "- Pipeline status: \`$STATUS\`" + echo "- [Abrir run](${RUN_URL})" + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "📣 Notificación enviada a Discord (HTTP $HTTP_CODE) — pipeline: ${STATUS}" From d32d7a6b313300e30f19dac38aa6705eefd50e98 Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 13:07:14 -0400 Subject: [PATCH 10/12] chore: bump version to 0.1.1 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index e4c4c17..630e0c9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,4 @@ plugins { alias(libs.plugins.android.library) apply false } -version = "0.1.0" \ No newline at end of file +version = "0.1.1" \ No newline at end of file From d8a76c5bc48c11c4467222f18d336855890c783f Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 13:45:35 -0400 Subject: [PATCH 11/12] ci: add branch argument to release workflow --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd5bd7c..3b6fef9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -524,6 +524,7 @@ jobs: --arg short_sha "$SHORT_SHA" \ --arg author "$AUTHOR" \ --arg event "$EVENT" \ + --arg branch "$BRANCH" \ --arg version "$VERSION_DISPLAY" \ --arg tag "$TAG_DISPLAY" \ --arg release_link "$ARTIFACT_LINK" \ From b5063ae1ab8edb04a1ffec948f8fb51d7c1af49b Mon Sep 17 00:00:00 2001 From: LeandroLCD Date: Fri, 14 Aug 2026 13:55:26 -0400 Subject: [PATCH 12/12] chore: update version to 0.1.11 --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 9d9b434..bc03463 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,4 +5,4 @@ plugins { alias(libs.plugins.android.library) apply false } -version = "0.1.1" +version = "0.1.11"