From 7e2100271914a6719879349c9851d0923ed88e5c Mon Sep 17 00:00:00 2001 From: vipulasri Date: Thu, 20 Aug 2026 11:26:07 -0400 Subject: [PATCH 1/9] ci: automate publish on tag push with GitHub Release --- .github/workflows/publish.yml | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a61fb29..9bb5dc4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,7 +1,10 @@ name: Publish -on: workflow_dispatch - +on: + push: + tags: + - 'v*' + jobs: publish: name: Publish @@ -19,6 +22,16 @@ jobs: - uses: gradle/actions/setup-gradle@v4 + - name: Extract version from tag + id: version + run: | + TAG="${GITHUB_REF#refs/tags/v}" + echo "version=$TAG" >> "$GITHUB_OUTPUT" + + - name: Update VERSION_NAME in gradle.properties + run: | + sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=${{ steps.version.outputs.version }}/" gradle.properties + - name: Build and Check run: ./gradlew build @@ -30,3 +43,21 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_KEY }} ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} + + release: + name: Create Release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true From b0426bb9be1424b11e0848daa96d1db2c11f80cc Mon Sep 17 00:00:00 2001 From: vipulasri Date: Thu, 20 Aug 2026 11:29:09 -0400 Subject: [PATCH 2/9] ci: auto-bump patch version, tag, and publish on main push --- .github/workflows/publish.yml | 61 ++++++++++++++++++++++------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9bb5dc4..aa31445 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -2,17 +2,22 @@ name: Publish on: push: - tags: - - 'v*' + branches: + - main jobs: publish: - name: Publish + name: Bump, Tag & Publish runs-on: macos-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 - name: Setup JDK 17 uses: actions/setup-java@v4 @@ -22,20 +27,43 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - - name: Extract version from tag - id: version + - name: Check if commit is version bump + id: check run: | - TAG="${GITHUB_REF#refs/tags/v}" - echo "version=$TAG" >> "$GITHUB_OUTPUT" + AUTHOR=$(git log -1 --format='%an') + if [ "$AUTHOR" = "github-actions[bot]" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi - - name: Update VERSION_NAME in gradle.properties + - name: Bump patch version + if: steps.check.outputs.skip == 'false' + id: bump run: | - sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=${{ steps.version.outputs.version }}/" gradle.properties + CURRENT=$(grep '^VERSION_NAME=' gradle.properties | cut -d'=' -f2) + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" + NEW_PATCH=$((PATCH + 1)) + NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}" + echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" + sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=$NEW_VERSION/" gradle.properties + + - name: Commit and tag + if: steps.check.outputs.skip == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add gradle.properties + git commit -m "chore: bump version to ${{ steps.bump.outputs.version }}" + git tag "v${{ steps.bump.outputs.version }}" + git push --follow-tags - name: Build and Check + if: steps.check.outputs.skip == 'false' run: ./gradlew build - name: Publish to Maven Central + if: steps.check.outputs.skip == 'false' run: ./gradlew publish --no-configuration-cache env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} @@ -44,20 +72,9 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} - release: - name: Create Release - needs: publish - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Create GitHub Release + if: steps.check.outputs.skip == 'false' uses: softprops/action-gh-release@v2 with: + tag_name: v${{ steps.bump.outputs.version }} generate_release_notes: true From 934b85d6f9958e0e33ed0bf1b071bde560d6432b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 20 Aug 2026 15:29:36 +0000 Subject: [PATCH 3/9] chore: bump version to 1.1.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index ccfb97a..8608a71 100644 --- a/gradle.properties +++ b/gradle.properties @@ -29,7 +29,7 @@ kotlin.native.ignoreDisabledTargets=true SONATYPE_HOST=CENTRAL_PORTAL RELEASE_SIGNING_ENABLED=true GROUP=com.vipulasri.aspecto -VERSION_NAME=1.1.0 +VERSION_NAME=1.1.1 POM_ARTIFACT_ID=aspecto POM_NAME=Aspecto From bcaa01f262656a049ef513e434bc3aa4432605a0 Mon Sep 17 00:00:00 2001 From: vipulasri Date: Thu, 20 Aug 2026 11:35:35 -0400 Subject: [PATCH 4/9] ci: build and test before version bump, publish before tag --- .github/workflows/publish.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aa31445..5af920d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,7 +7,7 @@ on: jobs: publish: - name: Bump, Tag & Publish + name: Build, Publish & Release runs-on: macos-latest permissions: contents: write @@ -37,6 +37,10 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" fi + - name: Build and Test + if: steps.check.outputs.skip == 'false' + run: ./gradlew build + - name: Bump patch version if: steps.check.outputs.skip == 'false' id: bump @@ -48,20 +52,6 @@ jobs: echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=$NEW_VERSION/" gradle.properties - - name: Commit and tag - if: steps.check.outputs.skip == 'false' - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add gradle.properties - git commit -m "chore: bump version to ${{ steps.bump.outputs.version }}" - git tag "v${{ steps.bump.outputs.version }}" - git push --follow-tags - - - name: Build and Check - if: steps.check.outputs.skip == 'false' - run: ./gradlew build - - name: Publish to Maven Central if: steps.check.outputs.skip == 'false' run: ./gradlew publish --no-configuration-cache @@ -72,6 +62,16 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} + - name: Commit and tag + if: steps.check.outputs.skip == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add gradle.properties + git commit -m "chore: bump version to ${{ steps.bump.outputs.version }}" + git tag "v${{ steps.bump.outputs.version }}" + git push --follow-tags + - name: Create GitHub Release if: steps.check.outputs.skip == 'false' uses: softprops/action-gh-release@v2 From b139aa47a26dd32414da56019e785f790a590072 Mon Sep 17 00:00:00 2001 From: vipulasri Date: Thu, 20 Aug 2026 11:37:10 -0400 Subject: [PATCH 5/9] ci: split publish workflow into build, publish, and release jobs --- .github/workflows/publish.yml | 62 +++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5af920d..bb1f664 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,18 +6,14 @@ on: - main jobs: - publish: - name: Build, Publish & Release + build-and-test: + name: Build & Test + if: github.event.head_commit.author.username != 'github-actions[bot]' runs-on: macos-latest - permissions: - contents: write steps: - name: Checkout uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - fetch-depth: 0 - name: Setup JDK 17 uses: actions/setup-java@v4 @@ -27,22 +23,27 @@ jobs: - uses: gradle/actions/setup-gradle@v4 - - name: Check if commit is version bump - id: check - run: | - AUTHOR=$(git log -1 --format='%an') - if [ "$AUTHOR" = "github-actions[bot]" ]; then - echo "skip=true" >> "$GITHUB_OUTPUT" - else - echo "skip=false" >> "$GITHUB_OUTPUT" - fi - - name: Build and Test - if: steps.check.outputs.skip == 'false' run: ./gradlew build + publish: + name: Publish to Maven Central + needs: build-and-test + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: '17' + + - uses: gradle/actions/setup-gradle@v4 + - name: Bump patch version - if: steps.check.outputs.skip == 'false' id: bump run: | CURRENT=$(grep '^VERSION_NAME=' gradle.properties | cut -d'=' -f2) @@ -53,7 +54,6 @@ jobs: sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=$NEW_VERSION/" gradle.properties - name: Publish to Maven Central - if: steps.check.outputs.skip == 'false' run: ./gradlew publish --no-configuration-cache env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} @@ -62,19 +62,31 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} + outputs: + version: ${{ steps.bump.outputs.version }} + + release: + name: Tag & Release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Commit and tag - if: steps.check.outputs.skip == 'false' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add gradle.properties - git commit -m "chore: bump version to ${{ steps.bump.outputs.version }}" - git tag "v${{ steps.bump.outputs.version }}" + git commit -m "chore: bump version to ${{ needs.publish.outputs.version }}" + git tag "v${{ needs.publish.outputs.version }}" git push --follow-tags - name: Create GitHub Release - if: steps.check.outputs.skip == 'false' uses: softprops/action-gh-release@v2 with: - tag_name: v${{ steps.bump.outputs.version }} + tag_name: v${{ needs.publish.outputs.version }} generate_release_notes: true From e0a40d6e90557a8dd9d991f815242803ac3f373f Mon Sep 17 00:00:00 2001 From: vipulasri Date: Thu, 20 Aug 2026 11:39:00 -0400 Subject: [PATCH 6/9] ci: add concurrency group and tag-exists check to prevent race conditions --- .github/workflows/publish.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bb1f664..5ebe6f7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,10 +5,16 @@ on: branches: - main +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: true + jobs: build-and-test: name: Build & Test - if: github.event.head_commit.author.username != 'github-actions[bot]' + if: >- + github.event.head_commit.author.username != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore: bump version') runs-on: macos-latest steps: @@ -53,7 +59,17 @@ jobs: echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" sed -i '' "s/^VERSION_NAME=.*/VERSION_NAME=$NEW_VERSION/" gradle.properties + - name: Check if tag already exists + id: tag_check + run: | + if git rev-parse "v${{ steps.bump.outputs.version }}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + - name: Publish to Maven Central + if: steps.tag_check.outputs.exists == 'false' run: ./gradlew publish --no-configuration-cache env: ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }} @@ -64,10 +80,12 @@ jobs: outputs: version: ${{ steps.bump.outputs.version }} + published: ${{ steps.tag_check.outputs.exists == 'false' }} release: name: Tag & Release needs: publish + if: needs.publish.outputs.published == 'true' runs-on: ubuntu-latest permissions: contents: write From 5c58772b303d35ac735c14f1b4bbf35348fa9469 Mon Sep 17 00:00:00 2001 From: vipulasri Date: Thu, 20 Aug 2026 12:04:07 -0400 Subject: [PATCH 7/9] feat: add RowDecoration support for full-width items in AspectoGrid Add ability to insert full-width row decorations (headers, footers, ads, loaders) at specific row positions via a mutable decorations list. - Add RowDecoration data class with index, key, contentType, content - Add isFullWidth flag to AspectoRow - Update calculateRows() to accept and splice decorations - Update AspectoGrid composable to render full-width decoration rows - Add key collision detection between decoration and item-derived keys - Add 10 unit tests for decoration behavior - Update sample app to demonstrate mutable decorations with pagination --- .../com/vipulasri/aspecto/AspectoGrid.kt | 44 +++- .../vipulasri/aspecto/AspectoLayoutInfo.kt | 20 ++ .../com/vipulasri/aspecto/AspectoRow.kt | 5 +- .../vipulasri/aspecto/AspectoRowCalculator.kt | 73 +++++- .../aspecto/AspectoRowCalculatorTest.kt | 226 ++++++++++++++++++ .../com/vipulasri/aspecto/sample/App.kt | 38 ++- 6 files changed, 393 insertions(+), 13 deletions(-) diff --git a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoGrid.kt b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoGrid.kt index 08e2783..05983f9 100644 --- a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoGrid.kt +++ b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoGrid.kt @@ -60,13 +60,19 @@ import androidx.compose.ui.unit.dp * padding value is used (consistent with [Arrangement.spacedBy]). For vertical spacing between * rows, only the top padding value is used. Asymmetric start/end or top/bottom values are * supported but only the start/top side is applied to inter-item and inter-row spacing. + * @param decorations Full-width row decorations to insert at specific row positions. Each + * decoration is rendered at the full available width before the regular row at its specified index. + * Useful for inserting ads, headers, footers, or loaders between grid rows. * @param content The grid content using [AspectoLayoutScope] * * Example usage: * ``` * AspectoGrid( * modifier = Modifier.fillMaxWidth(), - * contentPadding = PaddingValues(8.dp) + * contentPadding = PaddingValues(8.dp), + * decorations = listOf( + * RowDecoration(index = 3, key = "ad-1") { AdBanner() } + * ) * ) { * items( * items = imageList, @@ -89,6 +95,7 @@ fun AspectoGrid( contentPadding: PaddingValues = PaddingValues(0.dp), maxRowHeight: Dp = DEFAULT_MAX_ROW_HEIGHT_PX.dp, itemPadding: PaddingValues = PaddingValues(0.dp), + decorations: List = emptyList(), content: AspectoLayoutScope.() -> Unit ) { val scope = AspectoLayoutScope().apply(content) @@ -118,9 +125,13 @@ fun AspectoGrid( items = scope.items, availableWidth = availableWidth, maxRowHeight = maxRowHeightPx, - horizontalPadding = horizontalPaddingPx + horizontalPadding = horizontalPaddingPx, + decorations = decorations ) + // Build a lookup map for decoration content by key + val decorationMap = decorations.associateBy { it.key } + LazyColumn( state = state, contentPadding = contentPadding, @@ -129,14 +140,29 @@ fun AspectoGrid( items( items = rows, key = { row -> row.key }, - contentType = { row -> row.items.firstOrNull()?.contentType } + contentType = { row -> + if (row.isFullWidth) { + decorationMap[row.key]?.contentType + } else { + row.items.firstOrNull()?.contentType + } + } ) { row -> - AspectoRow( - row = row, - density = density, - itemPadding = itemPadding, - layoutDirection = layoutDirection - ) + if (row.isFullWidth) { + val decoration = decorationMap[row.key] + if (decoration != null) { + Box(modifier = Modifier.fillMaxWidth()) { + decoration.content() + } + } + } else { + AspectoRow( + row = row, + density = density, + itemPadding = itemPadding, + layoutDirection = layoutDirection + ) + } } } } diff --git a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoLayoutInfo.kt b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoLayoutInfo.kt index 22d2542..1b2ea5d 100644 --- a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoLayoutInfo.kt +++ b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoLayoutInfo.kt @@ -45,6 +45,26 @@ data class AspectoLayoutInfo( val height: Int = 0 ) +/** + * Represents a full-width row decoration inserted at a specific row position in the grid. + * + * Decorations are inserted before the regular row at the given [index]. For example, a decoration + * at `index = 3` appears between regular row 2 and regular row 3. + * + * @param index Row position (0-based) to insert this decoration before + * @param key Unique identifier for this decoration row, used for lazy list identity. + * Must not collide with keys from grid items. + * @param contentType Type of content for recomposition optimization (optional) + * @param content Composable content to be displayed at full width + */ +@Stable +data class RowDecoration( + val index: Int, + val key: Any, + val contentType: Any? = null, + val content: @Composable () -> Unit +) + /** * Scope for building grid content with aspect ratio-based items. */ diff --git a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRow.kt b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRow.kt index 68aa000..9d48988 100644 --- a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRow.kt +++ b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRow.kt @@ -25,9 +25,12 @@ import androidx.compose.runtime.Stable * @param key Stable identity for the row, derived from the first item's [AspectoLayoutInfo.key] * (or its index when no key is provided). Used as the lazy list item key so that appended items do * not invalidate existing rows. + * @param isFullWidth Whether this row is a full-width decoration row (not part of the + * aspect-ratio grid). Full-width rows render their content at the full available width. */ @Stable internal data class AspectoRow( val items: List = emptyList(), - val key: Any + val key: Any, + val isFullWidth: Boolean = false ) \ No newline at end of file diff --git a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRowCalculator.kt b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRowCalculator.kt index 03be2d1..1c60661 100644 --- a/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRowCalculator.kt +++ b/aspecto/src/commonMain/kotlin/com/vipulasri/aspecto/AspectoRowCalculator.kt @@ -34,12 +34,16 @@ internal const val DEFAULT_MAX_ROW_HEIGHT_PX = 600 * @param availableWidth Available width for the rows, in pixels. * @param maxRowHeight Maximum allowed height for any row, in pixels. * @param horizontalPadding Spacing between items within a row, in pixels. + * @param decorations Full-width row decorations to insert at specific row positions. + * Each decoration is inserted before the regular row at its [RowDecoration.index]. + * Sorted by index internally; empty list means no decorations. */ internal fun calculateRows( items: List, availableWidth: Int, maxRowHeight: Int = DEFAULT_MAX_ROW_HEIGHT_PX, - horizontalPadding: Int = 0 + horizontalPadding: Int = 0, + decorations: List = emptyList() ): List { val minRowHeight = (maxRowHeight * 0.5f).toInt() val rows = ArrayList(items.size / 2 + 1) @@ -76,7 +80,7 @@ internal fun calculateRows( currentIndex = rowConfig.endIndex } - return rows + return spliceDecorations(rows, decorations, rowKeys) } private fun rowKey( @@ -221,4 +225,69 @@ private fun calculateRowHeight( } return rowHeight.coerceIn(minRowHeight.toFloat(), maxRowHeight.toFloat()) +} + +/** + * Inserts [RowDecoration] rows into the computed regular rows at the specified positions. + * + * Each decoration is placed before the regular row at its [RowDecoration.index]. Decorations + * with indices beyond the last regular row are appended at the end. + * + * @param rows The computed regular rows. + * @param decorations The decorations to splice in, sorted by [RowDecoration.index]. + * @param existingKeys The set of keys already used by regular rows, used to detect collisions. + * @return A new list with decorations inserted at the correct positions. + */ +private fun spliceDecorations( + rows: List, + decorations: List, + existingKeys: HashSet +): List { + if (decorations.isEmpty()) return rows + + val sorted = decorations.sortedBy { it.index } + val result = ArrayList(rows.size + sorted.size) + var regularRowCounter = 0 + var decorationIdx = 0 + + for (row in rows) { + while (decorationIdx < sorted.size && sorted[decorationIdx].index == regularRowCounter) { + val decKey = sorted[decorationIdx].key + check(existingKeys.add(decKey)) { + "Duplicate row key '$decKey' detected for decoration at index " + + "${sorted[decorationIdx].index}. Decoration keys must be unique and must " + + "not collide with item-derived row keys." + } + result.add( + AspectoRow( + items = emptyList(), + key = decKey, + isFullWidth = true + ) + ) + decorationIdx++ + } + result.add(row) + regularRowCounter++ + } + + // Append remaining decorations (index >= regular row count) + while (decorationIdx < sorted.size) { + val decKey = sorted[decorationIdx].key + check(existingKeys.add(decKey)) { + "Duplicate row key '$decKey' detected for decoration at index " + + "${sorted[decorationIdx].index}. Decoration keys must be unique and must " + + "not collide with item-derived row keys." + } + result.add( + AspectoRow( + items = emptyList(), + key = decKey, + isFullWidth = true + ) + ) + decorationIdx++ + } + + return result } \ No newline at end of file diff --git a/aspecto/src/commonTest/kotlin/com/vipulasri/aspecto/AspectoRowCalculatorTest.kt b/aspecto/src/commonTest/kotlin/com/vipulasri/aspecto/AspectoRowCalculatorTest.kt index ac72136..1dccf69 100644 --- a/aspecto/src/commonTest/kotlin/com/vipulasri/aspecto/AspectoRowCalculatorTest.kt +++ b/aspecto/src/commonTest/kotlin/com/vipulasri/aspecto/AspectoRowCalculatorTest.kt @@ -509,6 +509,221 @@ class AspectoRowCalculatorTest { assertTrue(avgWidthSmall > avgWidthLarge) } + // region Decoration tests + + @Test + fun `should insert decoration at correct row index`() { + + // Given - 4 items that produce 2 rows (3+1) + val items = List(4) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 1, key = "dec-0") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then - decoration appears between row 0 and row 1 + assertEquals(3, rows.size) + assertEquals(false, rows[0].isFullWidth) + assertEquals(true, rows[1].isFullWidth) + assertEquals("dec-0", rows[1].key) + assertEquals(false, rows[2].isFullWidth) + } + + @Test + fun `should insert decoration before first row when index is 0`() { + + // Given + val items = List(3) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 0, key = "header") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then + assertEquals(2, rows.size) + assertEquals(true, rows[0].isFullWidth) + assertEquals("header", rows[0].key) + assertEquals(false, rows[1].isFullWidth) + } + + @Test + fun `should append decoration after last row when index exceeds row count`() { + + // Given - 3 items that produce 1 row + val items = List(3) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 5, key = "footer") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then + assertEquals(2, rows.size) + assertEquals(false, rows[0].isFullWidth) + assertEquals(true, rows[1].isFullWidth) + assertEquals("footer", rows[1].key) + } + + @Test + fun `should insert multiple decorations at different indices`() { + + // Given - 6 items that produce 2 rows (3+3) + val items = List(6) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 0, key = "header") { }, + RowDecoration(index = 1, key = "ad-1") { }, + RowDecoration(index = 2, key = "footer") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then - header, row0, ad-1, row1, footer + assertEquals(5, rows.size) + assertEquals(listOf(true, false, true, false, true), rows.map { it.isFullWidth }) + assertEquals(listOf("header", "ad-1", "footer"), rows.filter { it.isFullWidth }.map { it.key }) + } + + @Test + fun `should not affect regular row layout when decorations are present`() { + + // Given + val items = List(4) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 1, key = "ad") { } + ) + + // When + val rowsWithDecorations = layoutWithDecorations(items, decorations) + val rowsWithout = layout(items) + + // Then - regular rows should have identical dimensions + val regularRowsWith = rowsWithDecorations.filter { !it.isFullWidth } + assertEquals(rowsWithout.size, regularRowsWith.size) + for (i in rowsWithout.indices) { + assertEquals(rowsWithout[i].items.size, regularRowsWith[i].items.size) + assertEquals(rowsWithout[i].items.map { it.width }, regularRowsWith[i].items.map { it.width }) + assertEquals(rowsWithout[i].items.map { it.height }, regularRowsWith[i].items.map { it.height }) + } + } + + @Test + fun `empty decorations list should produce same result as no decorations`() { + + // Given + val items = List(4) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + + // When + val rowsEmpty = layoutWithDecorations(items, emptyList()) + val rowsDefault = layout(items) + + // Then + assertEquals(rowsDefault.size, rowsEmpty.size) + assertEquals(rowsDefault.map { it.key }, rowsEmpty.map { it.key }) + } + + @Test + fun `decoration keys should not collide with item-derived row keys`() { + + // Given - items without keys (row keys derived from index) + val items = List(3) { createTestItem(aspectRatio = 1.0f) } + val decorations = listOf( + RowDecoration(index = 0, key = 0) { } // key = 0 collides with first item row key + ) + + // Then - should throw due to duplicate key + val exception = assertFailsWith { + layoutWithDecorations(items, decorations) + } + assertTrue(exception.message.orEmpty().contains("Duplicate row key")) + } + + @Test + fun `decorations with non colliding keys should work`() { + + // Given + val items = List(3) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 1, key = "dec-unique") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then + val allKeys = rows.map { it.key } + assertEquals(2, allKeys.distinct().size) + } + + @Test + fun `should handle decoration at every row position`() { + + // Given - 6 items producing 2 rows, decoration between each + val items = List(6) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 0, key = "d0") { }, + RowDecoration(index = 1, key = "d1") { }, + RowDecoration(index = 2, key = "d2") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then - d0, row0, d1, row1, d2 + assertEquals(5, rows.size) + assertEquals( + listOf("d0", "item0", "d1", "item3", "d2"), + rows.map { it.key } + ) + } + + @Test + fun `should handle multiple decorations at same index`() { + + // Given + val items = List(3) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 0, key = "d-a") { }, + RowDecoration(index = 0, key = "d-b") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then - both decorations before row 0 + assertEquals(3, rows.size) + assertEquals(true, rows[0].isFullWidth) + assertEquals("d-a", rows[0].key) + assertEquals(true, rows[1].isFullWidth) + assertEquals("d-b", rows[1].key) + assertEquals(false, rows[2].isFullWidth) + } + + @Test + fun `should handle out of order decorations by sorting them`() { + + // Given + val items = List(6) { createTestItem(aspectRatio = 1.0f, key = "item$it") } + val decorations = listOf( + RowDecoration(index = 1, key = "d-last") { }, + RowDecoration(index = 0, key = "d-first") { } + ) + + // When + val rows = layoutWithDecorations(items, decorations) + + // Then - sorted: d-first before row0, d-last before row1 + assertEquals(4, rows.size) + assertEquals(listOf("d-first", "item0", "d-last", "item3"), rows.map { it.key }) + } + + // endregion + private fun createTestItem( aspectRatio: Float, key: String? = null @@ -518,4 +733,15 @@ class AspectoRowCalculatorTest { contentType = null, content = @Composable {} ) + + private fun layoutWithDecorations( + items: List, + decorations: List + ) = calculateRows( + items = items, + availableWidth = AVAILABLE_WIDTH, + maxRowHeight = MAX_ROW_HEIGHT, + horizontalPadding = HORIZONTAL_PADDING, + decorations = decorations + ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt index 04e42da..6376c98 100644 --- a/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt +++ b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt @@ -49,6 +49,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import coil3.util.DebugLogger import com.vipulasri.aspecto.AspectoGrid +import com.vipulasri.aspecto.RowDecoration import com.vipulasri.aspecto.sample.ui.theme.AspectoTheme import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged @@ -58,6 +59,7 @@ private const val MAX_PAGES = 6 private const val REMOVE_SIZE = 10 private const val LOAD_DELAY_MS = 600L private const val APPEND_THRESHOLD_ROWS = 3 +private const val AD_INTERVAL_ROWS = 3 @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable @@ -81,6 +83,7 @@ fun ArtworkScreen() { var items by remember { mutableStateOf(getItems().take(PAGE_SIZE)) } var currentPage by remember { mutableStateOf(1) } var isAppending by remember { mutableStateOf(false) } + var decorations by remember { mutableStateOf(emptyList()) } // Append pagination: when the last visible row is within APPEND_THRESHOLD_ROWS of the end. LaunchedEffect(state) { @@ -98,6 +101,38 @@ fun ArtworkScreen() { currentPage += 1 val newItems = getItems().take(PAGE_SIZE).map { it.copy(id = "append-${currentPage}-${it.id}") } items += newItems + + // Update decorations: add ad after every AD_INTERVAL_ROWS + val totalItems = items.size + val estimatedRows = totalItems / 3 // rough estimate: ~3 items per row + val newDecorations = mutableListOf() + var row = AD_INTERVAL_ROWS + while (row < estimatedRows) { + newDecorations.add( + RowDecoration( + index = row, + key = "ad-${row}", + contentType = "ad" + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Advertisement (row $row)", + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Bold + ) + } + } + ) + row += AD_INTERVAL_ROWS + } + decorations = newDecorations + isAppending = false } } @@ -124,7 +159,8 @@ fun ArtworkScreen() { state = state, maxRowHeight = 250.dp, itemPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), - contentPadding = PaddingValues(4.dp) + contentPadding = PaddingValues(4.dp), + decorations = decorations ) { items( items = items, From 8f4c661ca4bb57872b8ae7f427421040361a5d28 Mon Sep 17 00:00:00 2001 From: vipulasri Date: Sat, 22 Aug 2026 14:39:13 -0400 Subject: [PATCH 8/9] feat: add RowDecoration support, sample app, and benchmarks - RowDecoration data class for full-width items at specific row positions - spliceDecorations() with key collision detection - calculateRows() accepts decorations parameter - AspectoGrid renders full-width decoration rows - 10 unit tests for decoration behavior - Benchmark tests with percentiles (p50/p75/p90/p95/p99) - Sample app: BasicGrid + DecoratedGrid with previews - Scaffold padding fix for nested screens - CMP tooling dependencies for previews - build.yml CI workflow --- .github/workflows/build.yml | 55 ++ .github/workflows/publish.yml | 76 +-- androidApp/build.gradle.kts | 1 - .../AspectoRowCalculatorBenchmarkTest.kt | 407 ++++++++++++- benchmark/benchmark-results.json | 95 ++++ gradle.properties | 4 +- gradle/libs.versions.toml | 2 + shared/build.gradle.kts | 5 + .../com/vipulasri/aspecto/sample/App.kt | 537 +++--------------- .../vipulasri/aspecto/sample/ArtworkItems.kt | 298 ++++++++++ .../com/vipulasri/aspecto/sample/BasicGrid.kt | 131 +++++ .../vipulasri/aspecto/sample/DecoratedGrid.kt | 193 +++++++ 12 files changed, 1281 insertions(+), 523 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 benchmark/benchmark-results.json create mode 100644 shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/ArtworkItems.kt create mode 100644 shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/BasicGrid.kt create mode 100644 shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/DecoratedGrid.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b027bec --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,55 @@ +name: Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + +concurrency: + group: pipeline-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & Test + if: >- + github.event.head_commit.author.username != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore: bump version') + runs-on: macos-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'zulu' + java-version: '17' + + - uses: gradle/actions/setup-gradle@v5 + + - name: Cache Kotlin/Native + uses: actions/cache@v5 + with: + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: konan-${{ runner.os }}- + + - name: Build and Test + run: ./gradlew build + + publish: + name: Publish & Release + needs: build + if: >- + github.ref == 'refs/heads/main' && + github.event.head_commit.author.username != 'github-actions[bot]' && + !startsWith(github.event.head_commit.message, 'chore: bump version') + uses: ./.github/workflows/publish.yml + secrets: inherit + permissions: + contents: write diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5ebe6f7..4d69e22 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,53 +1,35 @@ name: Publish on: - push: - branches: - - main - -concurrency: - group: publish-${{ github.ref }} - cancel-in-progress: true + workflow_call: jobs: - build-and-test: - name: Build & Test - if: >- - github.event.head_commit.author.username != 'github-actions[bot]' && - !startsWith(github.event.head_commit.message, 'chore: bump version') + publish: + name: Publish & Release runs-on: macos-latest + permissions: + contents: write steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 + with: + ref: main - name: Setup JDK 17 - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: 'zulu' java-version: '17' - - uses: gradle/actions/setup-gradle@v4 - - - name: Build and Test - run: ./gradlew build + - uses: gradle/actions/setup-gradle@v5 - publish: - name: Publish to Maven Central - needs: build-and-test - runs-on: macos-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup JDK 17 - uses: actions/setup-java@v4 + - name: Cache Kotlin/Native + uses: actions/cache@v5 with: - distribution: 'zulu' - java-version: '17' - - - uses: gradle/actions/setup-gradle@v4 + path: ~/.konan + key: konan-${{ runner.os }}-${{ hashFiles('gradle/libs.versions.toml') }} + restore-keys: konan-${{ runner.os }}- - name: Bump patch version id: bump @@ -78,33 +60,19 @@ jobs: ORG_GRADLE_PROJECT_signingInMemoryKeyId: ${{ secrets.GPG_KEY_ID }} ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_KEY_PASSWORD }} - outputs: - version: ${{ steps.bump.outputs.version }} - published: ${{ steps.tag_check.outputs.exists == 'false' }} - - release: - name: Tag & Release - needs: publish - if: needs.publish.outputs.published == 'true' - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Commit and tag + - name: Commit, tag & push + if: steps.tag_check.outputs.exists == 'false' run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add gradle.properties - git commit -m "chore: bump version to ${{ needs.publish.outputs.version }}" - git tag "v${{ needs.publish.outputs.version }}" + git commit -m "chore: bump version to ${{ steps.bump.outputs.version }}" + git tag "v${{ steps.bump.outputs.version }}" git push --follow-tags - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + if: steps.tag_check.outputs.exists == 'false' + uses: softprops/action-gh-release@v3 with: - tag_name: v${{ needs.publish.outputs.version }} + tag_name: v${{ steps.bump.outputs.version }} generate_release_notes: true diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index c208fce..c094fa4 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -48,7 +48,6 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.tooling) implementation(libs.ktor.client.cio) debugImplementation(libs.androidx.compose.tooling) } diff --git a/aspecto/src/androidHostTest/kotlin/com.vipulasri.aspecto/AspectoRowCalculatorBenchmarkTest.kt b/aspecto/src/androidHostTest/kotlin/com.vipulasri.aspecto/AspectoRowCalculatorBenchmarkTest.kt index 8a493d9..47a83bd 100644 --- a/aspecto/src/androidHostTest/kotlin/com.vipulasri.aspecto/AspectoRowCalculatorBenchmarkTest.kt +++ b/aspecto/src/androidHostTest/kotlin/com.vipulasri.aspecto/AspectoRowCalculatorBenchmarkTest.kt @@ -5,6 +5,11 @@ import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.concurrent.TimeUnit /** * Micro-benchmark (JVM/Robolectric) for [calculateRows] — not a correctness test. @@ -28,6 +33,7 @@ class AspectoRowCalculatorBenchmarkTest { private const val AVAILABLE_WIDTH = 1000 private const val WARMUP_ITERATIONS = 5 private const val MEASURE_ITERATIONS = 7 + private const val AD_INTERVAL_ROWS = 3 private val ITEM_COUNTS = intArrayOf(1_000, 10_000, 100_000) } @@ -201,49 +207,408 @@ class AspectoRowCalculatorBenchmarkTest { } } + // region Decoration benchmarks + + private fun buildDecorations( + rowCount: Int, + adInterval: Int = AD_INTERVAL_ROWS + ): List { + val decorations = mutableListOf() + decorations.add(RowDecoration(index = 0, key = "header") {}) + var row = adInterval + while (row < rowCount) { + decorations.add(RowDecoration(index = row, key = "ad-$row", contentType = "ad") {}) + row += adInterval + } + return decorations + } + + private fun timeMillisWithDecorations(count: Int, ratioAt: (Int) -> Float): Double { + val items = buildItems(count, ratioAt) + val estimatedRows = count / 3 + val decorations = buildDecorations(estimatedRows) + repeat(WARMUP_ITERATIONS) { + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, decorations) + } + var best = Double.MAX_VALUE + repeat(MEASURE_ITERATIONS) { + val start = System.nanoTime() + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, decorations) + val elapsed = (System.nanoTime() - start) / 1_000_000.0 + if (elapsed < best) best = elapsed + } + return best + } + + private fun timeMillisWithoutDecorations(count: Int, ratioAt: (Int) -> Float): Double { + val items = buildItems(count, ratioAt) + repeat(WARMUP_ITERATIONS) { + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING) + } + var best = Double.MAX_VALUE + repeat(MEASURE_ITERATIONS) { + val start = System.nanoTime() + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING) + val elapsed = (System.nanoTime() - start) / 1_000_000.0 + if (elapsed < best) best = elapsed + } + return best + } + @Test - fun `row keys stay stable and deterministic across add and remove`() { + fun `decorated calculateRows time scales linearly with item count`() { + val times = ITEM_COUNTS.associateWith { timeMillisWithDecorations(it, realisticRatio()) } + println("\n=== decorated calculateRows time scaling ===") + times.forEach { (count, ms) -> + println(" items=%,8d best=%8.3f ms (%,10d items/ms)".format(count, ms, (count / ms).toInt())) + } + + val (t1k, t100k) = times[1_000]!! to times[100_000]!! + val ratio = t100k / t1k + println(" 100k/1k time ratio = %.1fx (linear ≈ 100x)".format(ratio)) + + assertTrue( + t100k <= t1k * 250 + 10, + "decorated 100k took ${"%.2f".format(t100k)}ms vs ${"%.2f".format(t1k)}ms for 1k " + + "(ratio ${"%.1f".format(ratio)}x) — looks super-linear!" + ) + } + + @Test + fun `decoration overhead is minimal compared to plain layout`() { + val ratioAt = realisticRatio() + println("\n=== decoration overhead ===") + println(" ${"items".padStart(10)} ${"plain (ms)".padStart(12)} ${"decorated (ms)".padEnd(14)} ${"overhead".padStart(10)}") + + for (count in ITEM_COUNTS) { + val plain = timeMillisWithoutDecorations(count, ratioAt) + val decorated = timeMillisWithDecorations(count, ratioAt) + val overhead = (decorated - plain) / plain * 100 + println( + " ${"$count".padStart(10)} ${"%.3f".format(plain).padStart(12)} " + + "${"%.3f".format(decorated).padEnd(14)} ${"%.1f%%".format(overhead).padStart(10)}" + ) + // SpliceDecorations is a single O(rows) pass; it should not double the time. + assertTrue( + decorated <= plain * 2.5 + 5, + "decorated layout on ${count} items took ${"%.2f".format(decorated)}ms vs " + + "${"%.2f".format(plain)}ms plain — decoration overhead too high!" + ) + } + } + + @Test + fun `decorated row keys stay stable and deterministic across add and remove`() { val sizes = listOf(10 to "few", 1_000 to "medium", 100_000 to "large") for ((size, label) in sizes) { val items = buildKeyedItems(size, realisticRatio()) - val original = calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING) + val estimatedRows = size / 3 + val decorations = buildDecorations(estimatedRows) + val original = calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, decorations) for (mutation in Mutation.entries) { val mutated = applyMutation(items, mutation) - val rows = calculateRows(mutated, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING) + val mutatedEstimatedRows = mutated.size / 3 + val mutatedDecorations = buildDecorations(mutatedEstimatedRows) + val rows = calculateRows(mutated, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, mutatedDecorations) - // Determinism: the same input always yields the same rows. - val recomputed = calculateRows(mutated, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING) - assertEquals(rows, recomputed, "$label ${mutation.label} must be deterministic") + // Determinism + val recomputed = calculateRows(mutated, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, mutatedDecorations) + assertEquals(rows, recomputed, "$label ${mutation.label} decorated must be deterministic") - // Every row key equals the key of its first item, and rows cover the dataset exactly. + // All keys are unique + val keys = rows.map { it.key } + assertEquals(keys.size, keys.toSet().size, "$label ${mutation.label} decorated has duplicate keys") + + // Regular rows cover all items var itemCursor = 0 for (row in rows) { - assertEquals(mutated[itemCursor].key, row.key, "$label ${mutation.label} row key") - itemCursor += row.items.size + if (!row.isFullWidth) { + assertEquals(mutated[itemCursor].key, row.key, "$label ${mutation.label} regular row key") + itemCursor += row.items.size + } } assertEquals(mutated.size, itemCursor, "$label ${mutation.label} covers all items") } - // Appending and truncating at the end must never disturb existing rows. + // Append-at-end preserves existing row keys + val appendedDecorations = buildDecorations((size + 1) / 3) val appended = calculateRows( - items + keyedItem(2.0f, -1), AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING + items + keyedItem(2.0f, -1), AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, appendedDecorations ) + val originalRegularKeys = original.filter { !it.isFullWidth }.map { it.key } + val appendedRegularKeys = appended.filter { !it.isFullWidth }.map { it.key } assertEquals( - original.map { it.key }, - appended.map { it.key }.take(original.size), - "$label append-at-end preserved existing row keys" + originalRegularKeys, + appendedRegularKeys.take(originalRegularKeys.size), + "$label decorated append-at-end preserved existing regular row keys" ) + } + } - val truncated = calculateRows( - items.dropLast(1), AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING - ) - assertEquals( - original.map { it.key }.take(truncated.size), - truncated.map { it.key }, - "$label remove-at-end preserved existing row keys" + @Test + fun `high decoration density scales linearly`() { + // Scenario: a decoration between every row (header + ad every row) + val ratioAt = realisticRatio() + println("\n=== high decoration density (decoration every row) ===") + + val times = mutableMapOf() + for (count in ITEM_COUNTS) { + val items = buildItems(count, ratioAt) + val estimatedRows = count / 3 + // Decorations at every row index (very dense) + val decorations = List(estimatedRows) { i -> + RowDecoration(index = i, key = "dense-$i") {} + } + repeat(WARMUP_ITERATIONS) { + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, decorations) + } + var best = Double.MAX_VALUE + repeat(MEASURE_ITERATIONS) { + val start = System.nanoTime() + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, decorations) + val elapsed = (System.nanoTime() - start) / 1_000_000.0 + if (elapsed < best) best = elapsed + } + times[count] = best + println(" items=%,8d rows=%,6d decorations=%,6d best=%8.3f ms".format( + count, estimatedRows, estimatedRows, best + )) + } + + val (t1k, t100k) = times[1_000]!! to times[100_000]!! + val ratio = t100k / t1k + println(" 100k/1k time ratio = %.1fx (linear ≈ 100x)".format(ratio)) + + assertTrue( + t100k <= t1k * 250 + 10, + "high-density decorated 100k took ${"%.2f".format(t100k)}ms vs " + + "${"%.2f".format(t1k)}ms for 1k (ratio ${"%.1f".format(ratio)}x) — looks super-linear!" + ) + } + + // endregion + + // region Percentile helpers + + private data class PercentileResult( + val min: Double, + val p50: Double, + val p75: Double, + val p90: Double, + val p95: Double, + val p99: Double, + val max: Double, + val iterations: Int + ) { + fun format(): String = + "min=${"%.3f".format(min)} p50=${"%.3f".format(p50)} p75=${"%.3f".format(p75)} " + + "p90=${"%.3f".format(p90)} p95=${"%.3f".format(p95)} p99=${"%.3f".format(p99)} " + + "max=${"%.3f".format(max)} ms (n=$iterations)" + } + + private fun computePercentiles(samples: List): PercentileResult { + val sorted = samples.sorted() + return PercentileResult( + min = sorted.first(), + p50 = percentile(sorted, 50), + p75 = percentile(sorted, 75), + p90 = percentile(sorted, 90), + p95 = percentile(sorted, 95), + p99 = percentile(sorted, 99), + max = sorted.last(), + iterations = sorted.size + ) + } + + private fun percentile(sorted: List, p: Int): Double { + val index = (p / 100.0) * (sorted.size - 1) + val lower = index.toInt() + val upper = lower + 1 + if (upper >= sorted.size) return sorted.last() + val fraction = index - lower + return sorted[lower] + (sorted[upper] - sorted[lower]) * fraction + } + + private fun collectTimings( + iterations: Int, + block: () -> Unit + ): List { + // Warmup + repeat(WARMUP_ITERATIONS) { block() } + // Collect + return List(iterations) { + val start = System.nanoTime() + block() + TimeUnit.NANOSECONDS.toMicros(System.nanoTime() - start) / 1000.0 + } + } + + private fun collectTimings( + count: Int, + ratioAt: (Int) -> Float, + iterations: Int, + decorations: List? = null + ): List { + val items = buildItems(count, ratioAt) + return collectTimings(iterations) { + if (decorations != null) { + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, decorations) + } else { + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING) + } + } + } + + private fun collectHighDensityTimings( + count: Int, + ratioAt: (Int) -> Float, + iterations: Int + ): List { + val items = buildItems(count, ratioAt) + val estimatedRows = count / 3 + val decorations = List(estimatedRows) { i -> + RowDecoration(index = i, key = "dense-$i") {} + } + return collectTimings(iterations) { + calculateRows(items, AVAILABLE_WIDTH, MAX_ROW_HEIGHT, HORIZONTAL_PADDING, decorations) + } + } + + // endregion + + // region Report generation + + private data class BenchmarkScenario( + val name: String, + val itemCounts: List, + val collect: (count: Int) -> List + ) + + @Test + fun `generate benchmark report with percentiles`() { + val iterations = 30 + val ratioAt = realisticRatio() + val estimatedRows = { count: Int -> count / 3 } + + val scenarios = listOf( + BenchmarkScenario("plain", ITEM_COUNTS.toList()) { count -> + collectTimings(count, ratioAt, iterations, decorations = null) + }, + BenchmarkScenario("decorated", ITEM_COUNTS.toList()) { count -> + val decs = buildDecorations(estimatedRows(count)) + collectTimings(count, ratioAt, iterations, decorations = decs) + }, + BenchmarkScenario("high_density", ITEM_COUNTS.toList()) { count -> + collectHighDensityTimings(count, ratioAt, iterations) + } + ) + + val results = mutableMapOf>() + + println("\n=== Benchmark Report ($iterations iterations) ===\n") + + for (scenario in scenarios) { + println("--- ${scenario.name} ---") + val byCount = mutableMapOf() + for (count in scenario.itemCounts) { + val timings = scenario.collect(count) + val pct = computePercentiles(timings) + byCount[count] = pct + println(" items=%,8d %s".format(count, pct.format())) + } + results[scenario.name] = byCount + println() + } + + // Overhead comparison + println("--- overhead (decorated vs plain) ---") + for (count in ITEM_COUNTS) { + val plain = results["plain"]!![count]!! + val decorated = results["decorated"]!![count]!! + val overheadP50 = (decorated.p50 - plain.p50) / plain.p50 * 100 + val overheadP99 = (decorated.p99 - plain.p99) / plain.p99 * 100 + println( + " items=%,8d p50 overhead=%6.1f%% p99 overhead=%6.1f%%".format( + count, overheadP50, overheadP99 + ) ) } + println() + + // Linearity check + println("--- linearity (100k / 1k ratio, lower = better, linear ≈ 100x) ---") + for (scenario in scenarios) { + val t1k = results[scenario.name]!![1_000]!! + val t100k = results[scenario.name]!![100_000]!! + val ratioP50 = t100k.p50 / t1k.p50 + val ratioP99 = t100k.p99 / t1k.p99 + println(" %-14s p50 ratio=%6.1fx p99 ratio=%6.1fx".format(scenario.name, ratioP50, ratioP99)) + } + println() + + // Write JSON report + writeReport(results, iterations) + } + + private fun writeReport( + results: Map>, + iterations: Int + ) { + val timestamp = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).format(Date()) + val gitCommit = runCatching { + Runtime.getRuntime().exec(arrayOf("git", "rev-parse", "--short", "HEAD")) + .inputStream.bufferedReader().readText().trim() + }.getOrDefault("unknown") + val gitBranch = runCatching { + Runtime.getRuntime().exec(arrayOf("git", "rev-parse", "--abbrev-ref", "HEAD")) + .inputStream.bufferedReader().readText().trim() + }.getOrDefault("unknown") + + val sb = StringBuilder() + sb.appendLine("{") + sb.appendLine(" \"timestamp\": \"$timestamp\",") + sb.appendLine(" \"gitCommit\": \"$gitCommit\",") + sb.appendLine(" \"gitBranch\": \"$gitBranch\",") + sb.appendLine(" \"iterations\": $iterations,") + sb.appendLine(" \"results\": {") + + val scenarioEntries = results.entries.toList() + for (sIdx in scenarioEntries.indices) { + val scenarioName = scenarioEntries[sIdx].key + val scenarioCounts = scenarioEntries[sIdx].value + sb.appendLine(" \"$scenarioName\": {") + val countEntries = scenarioCounts.entries.toList() + for (cIdx in countEntries.indices) { + val countKey = countEntries[cIdx].key + val pct = countEntries[cIdx].value + val comma = if (cIdx < countEntries.size - 1) "," else "" + sb.appendLine(" \"$countKey\": {") + sb.appendLine(" \"min\": ${pct.min},") + sb.appendLine(" \"p50\": ${pct.p50},") + sb.appendLine(" \"p75\": ${pct.p75},") + sb.appendLine(" \"p90\": ${pct.p90},") + sb.appendLine(" \"p95\": ${pct.p95},") + sb.appendLine(" \"p99\": ${pct.p99},") + sb.appendLine(" \"max\": ${pct.max}") + sb.appendLine(" }$comma") + } + val comma = if (sIdx < scenarioEntries.size - 1) "," else "" + sb.appendLine(" }$comma") + } + + sb.appendLine(" }") + sb.appendLine("}") + + val projectRoot = File(System.getProperty("user.dir")).let { dir -> + generateSequence(dir) { it.parentFile } + .firstOrNull { File(it, "settings.gradle.kts").exists() } ?: dir + } + val reportFile = File(projectRoot, "benchmark/benchmark-results.json") + reportFile.writeText(sb.toString()) + println("Report written to: ${reportFile.absolutePath}") } + + // endregion } diff --git a/benchmark/benchmark-results.json b/benchmark/benchmark-results.json new file mode 100644 index 0000000..167efd4 --- /dev/null +++ b/benchmark/benchmark-results.json @@ -0,0 +1,95 @@ +{ + "timestamp": "2026-08-22T14:22:24", + "gitCommit": "aa5929a", + "gitBranch": "main", + "iterations": 30, + "results": { + "plain": { + "1000": { + "min": 0.099, + "p50": 0.121, + "p75": 0.1375, + "p90": 0.1812, + "p95": 0.21214999999999984, + "p99": 0.24097, + "max": 0.243 + }, + "10000": { + "min": 0.383, + "p50": 0.567, + "p75": 0.78125, + "p90": 0.9771000000000001, + "p95": 1.0655999999999999, + "p99": 3.9883900000000034, + "max": 5.18 + }, + "100000": { + "min": 2.608, + "p50": 3.6995, + "p75": 5.35475, + "p90": 7.1754000000000016, + "p95": 8.0009, + "p99": 8.809000000000001, + "max": 9.099 + } + }, + "decorated": { + "1000": { + "min": 0.031, + "p50": 0.0555, + "p75": 0.07675, + "p90": 0.09350000000000001, + "p95": 0.10679999999999996, + "p99": 0.15660000000000004, + "max": 0.174 + }, + "10000": { + "min": 0.323, + "p50": 0.335, + "p75": 0.3425, + "p90": 0.367, + "p95": 0.4183499999999998, + "p99": 0.45797000000000004, + "max": 0.46 + }, + "100000": { + "min": 3.224, + "p50": 4.1165, + "p75": 6.0045, + "p90": 7.2944, + "p95": 7.650449999999999, + "p99": 8.901560000000002, + "max": 9.376 + } + }, + "high_density": { + "1000": { + "min": 0.028, + "p50": 0.03, + "p75": 0.03175, + "p90": 0.034100000000000005, + "p95": 0.035, + "p99": 0.03571, + "max": 0.036 + }, + "10000": { + "min": 0.303, + "p50": 0.334, + "p75": 0.38075000000000003, + "p90": 0.4907000000000001, + "p95": 0.5577499999999999, + "p99": 1.4995800000000012, + "max": 1.876 + }, + "100000": { + "min": 3.653, + "p50": 4.481, + "p75": 5.5634999999999994, + "p90": 8.120000000000001, + "p95": 8.3387, + "p99": 10.538440000000001, + "max": 11.427 + } + } + } +} diff --git a/gradle.properties b/gradle.properties index 8608a71..d4f8bff 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. For more details, visit # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects @@ -22,7 +22,7 @@ kotlin.code.style=official # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true # The :benchmark module uses compileSdk 36 for androidx.benchmark 1.3.x (AGP 8.7.2 max tested is 35) -android.suppressUnsupportedCompileSdk=36 +android.suppressUnsupportedCompileSdk=37 kotlin.native.ignoreDisabledTargets=true #Publishing diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3ed8134..7dc1713 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -47,6 +47,8 @@ material = { group = "com.google.android.material", name = "material", version.r compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" } compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" } +compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" } +compose-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" } compose-material = { module = "org.jetbrains.compose.material3:material3", version = "1.9.0" } compose-material-icons = { module = "org.jetbrains.compose.material:material-icons-extended", version = "1.7.3" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index aa4ab3f..802e472 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -45,6 +45,7 @@ kotlin { implementation(libs.compose.material) implementation(libs.compose.material.icons) implementation(libs.bundles.coil) + implementation(libs.compose.ui.tooling.preview) } commonTest.dependencies { @@ -59,3 +60,7 @@ kotlin { } } } + +dependencies { + androidRuntimeClasspath(libs.compose.ui.tooling) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt index 6376c98..0d83cbb 100644 --- a/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt +++ b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/App.kt @@ -1,67 +1,42 @@ package com.vipulasri.aspecto.sample -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.ImageLoader -import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext import coil3.compose.setSingletonImageLoaderFactory -import coil3.request.ImageRequest import coil3.request.crossfade import coil3.util.DebugLogger -import com.vipulasri.aspecto.AspectoGrid -import com.vipulasri.aspecto.RowDecoration import com.vipulasri.aspecto.sample.ui.theme.AspectoTheme -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.distinctUntilChanged -private const val PAGE_SIZE = 20 -private const val MAX_PAGES = 6 -private const val REMOVE_SIZE = 10 -private const val LOAD_DELAY_MS = 600L -private const val APPEND_THRESHOLD_ROWS = 3 -private const val AD_INTERVAL_ROWS = 3 - -@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun App() { setSingletonImageLoaderFactory { context -> @@ -72,442 +47,114 @@ fun App() { } AspectoTheme { - ArtworkScreen() - } -} - -@Composable -fun ArtworkScreen() { - val state = rememberLazyListState() - - var items by remember { mutableStateOf(getItems().take(PAGE_SIZE)) } - var currentPage by remember { mutableStateOf(1) } - var isAppending by remember { mutableStateOf(false) } - var decorations by remember { mutableStateOf(emptyList()) } + var selectedScreen by remember { mutableStateOf(null) } - // Append pagination: when the last visible row is within APPEND_THRESHOLD_ROWS of the end. - LaunchedEffect(state) { - snapshotFlow { - val layoutInfo = state.layoutInfo - val total = layoutInfo.totalItemsCount - val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 - total > 0 && lastVisible >= 0 && lastVisible >= total - APPEND_THRESHOLD_ROWS - } - .distinctUntilChanged() - .collect { shouldLoad -> - if (shouldLoad && !isAppending && currentPage < MAX_PAGES) { - isAppending = true - delay(LOAD_DELAY_MS) - currentPage += 1 - val newItems = getItems().take(PAGE_SIZE).map { it.copy(id = "append-${currentPage}-${it.id}") } - items += newItems - - // Update decorations: add ad after every AD_INTERVAL_ROWS - val totalItems = items.size - val estimatedRows = totalItems / 3 // rough estimate: ~3 items per row - val newDecorations = mutableListOf() - var row = AD_INTERVAL_ROWS - while (row < estimatedRows) { - newDecorations.add( - RowDecoration( - index = row, - key = "ad-${row}", - contentType = "ad" - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.primaryContainer) - .padding(16.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "Advertisement (row $row)", - color = MaterialTheme.colorScheme.onPrimaryContainer, - fontWeight = FontWeight.Bold - ) - } - } - ) - row += AD_INTERVAL_ROWS - } - decorations = newDecorations - - isAppending = false - } + when (val screen = selectedScreen) { + null -> HomeScreen(onSelect = { selectedScreen = it }) + is Screen.Basic -> DetailScreen( + title = "Basic Grid", + onBack = { selectedScreen = null } + ) { padding -> + BasicGrid(modifier = Modifier.padding(padding)) + } + is Screen.WithHeader -> DetailScreen( + title = "With Header & Ads", + onBack = { selectedScreen = null } + ) { padding -> + DecoratedGrid(modifier = Modifier.padding(padding)) } + } } +} - fun removeLast() { - if (items.isEmpty()) return - items = items.take((items.size - REMOVE_SIZE).coerceAtLeast(0)) - } +private sealed class Screen { + data object Basic : Screen() + data object WithHeader : Screen() +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeScreen(onSelect: (Screen) -> Unit) { Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - AppTopAppBar( - itemCount = items.size, - canRemove = items.isNotEmpty(), - onRemove = ::removeLast + TopAppBar( + title = { Text("Aspecto Demo", fontWeight = FontWeight.Bold) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + titleContentColor = MaterialTheme.colorScheme.onSurface + ) ) } ) { padding -> - Box(modifier = Modifier.fillMaxSize().padding(padding)) { - AspectoGrid( - modifier = Modifier.fillMaxSize(), - state = state, - maxRowHeight = 250.dp, - itemPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), - contentPadding = PaddingValues(4.dp), - decorations = decorations + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Spacer(modifier = Modifier.weight(1f)) + Text( + text = "Choose a layout", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.SemiBold + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Basic grid or grid with full-width decorations", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(48.dp)) + Button( + onClick = { onSelect(Screen.Basic) }, + modifier = Modifier.fillMaxWidth() ) { - items( - items = items, - key = { it.id }, - aspectRatio = { it.aspectRatio } - ) { item -> - ArtworkItem(item = item) - } + Text("Basic", fontSize = 16.sp, modifier = Modifier.padding(vertical = 4.dp)) } - - // Bottom loading indicator (append) - AnimatedVisibility( - visible = isAppending, - enter = slideInVertically { it } + fadeIn(), - exit = slideOutVertically { it } + fadeOut(), - modifier = Modifier.align(Alignment.BottomCenter) + Spacer(modifier = Modifier.height(16.dp)) + OutlinedButton( + onClick = { onSelect(Screen.WithHeader) }, + modifier = Modifier.fillMaxWidth() ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) - .padding(16.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary - ) - Text( - text = "Loading page ${currentPage + 1}…", - modifier = Modifier.padding(start = 12.dp), - color = MaterialTheme.colorScheme.onSurface, - fontSize = 14.sp - ) - } + Text( + "With Header & Ads", + fontSize = 16.sp, + modifier = Modifier.padding(vertical = 4.dp) + ) } + Spacer(modifier = Modifier.weight(1f)) } } } -@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AppTopAppBar( - itemCount: Int, - canRemove: Boolean, - onRemove: () -> Unit +private fun DetailScreen( + title: String, + onBack: () -> Unit, + content: @Composable (PaddingValues) -> Unit ) { - TopAppBar( - title = { - Column { - Text("Aspecto Demo", fontWeight = FontWeight.Bold) - Text( - text = "$itemCount items", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(title, fontWeight = FontWeight.Bold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + titleContentColor = MaterialTheme.colorScheme.onSurface ) - } - }, - actions = { - IconButton(onClick = onRemove, enabled = canRemove) { - Icon(Icons.Default.Delete, contentDescription = "Remove last $REMOVE_SIZE items") - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer, - titleContentColor = MaterialTheme.colorScheme.onSurface - ) - ) -} - -@Composable -fun ArtworkItem(item: Artwork) { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceVariant) - ) { - AsyncImage( - model = ImageRequest.Builder(LocalPlatformContext.current) - .data(item.imageUrl) - .build(), - contentDescription = item.title, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.45f)) - .padding(8.dp), - contentAlignment = Alignment.BottomStart - ) { - Text( - text = item.title, - color = Color.White, - fontWeight = FontWeight.Medium, - fontSize = 12.sp, - maxLines = 2, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis ) } + ) { padding -> + content(padding) } } - -private fun getItems(): List { - return listOf( - Artwork( - id = "1", - aspectRatio = 2000f / 1446f, - imageUrl = "https://uploads0.wikiart.org/00475/images/salvador-dali/w1siziisijm4njq3mcjdlfsiccisimnvbnzlcnqilcitcxvhbgl0esa5mcatcmvzaxplidiwmdb4mjawmfx1mdazzsjdxq.jpg", - title = "The Persistence of Memory" - ), - Artwork( - id = "2", - aspectRatio = 1020f / 1500f, - imageUrl = "https://uploads8.wikiart.org/00339/images/leonardo-da-vinci/mona-lisa-c-1503-1519.jpg", - title = "Mona Lisa" - ), - Artwork( - id = "3", - aspectRatio = 2000f / 1594f, - imageUrl = "https://uploads3.wikiart.org/00142/images/vincent-van-gogh/the-starry-night.jpg", - title = "The Starry Night" - ), - Artwork( - id = "4", - aspectRatio = 5773f / 4478f, - imageUrl = "https://uploads8.wikiart.org/00129/images/claude-monet/impression-sunrise.jpg", - title = "Impression, sunrise" - ), - Artwork( - id = "5", - aspectRatio = 3369f / 1523f, - imageUrl = "https://uploads8.wikiart.org/00139/images/pablo-picasso/guernica-by-pablo-picasso.jpg", - title = "Guernica" - ), - Artwork( - id = "6", - aspectRatio = 3000f / 2190f, - imageUrl = "https://uploads1.wikiart.org/images/salvador-dali/the-great-masturbator-1929.jpg", - title = "The Great Masturbator" - ), - Artwork( - id = "7", - aspectRatio = 1280f / 812f, - imageUrl = "https://uploads0.wikiart.org/00242/images/alexandre-cabanel/fallen-angel-alexandre-cabanel.jpg", - title = "Fallen Angel" - ), - Artwork( - id = "8", - aspectRatio = 5000f / 5017f, - imageUrl = "https://uploads6.wikiart.org/00142/images/57726d7eedc2cb3880b47e13/the-kiss-gustav-klimt-google-cultural-institute.jpg", - title = "The Kiss" - ), - Artwork( - id = "9", - aspectRatio = 3524f / 1599f, - imageUrl = "https://uploads8.wikiart.org/00475/images/michelangelo/the-creation-of-adam-michelangelo-c-1512.jpg", - title = "Sistine Chapel Ceiling: Creation of Adam" - ), - Artwork( - id = "10", - aspectRatio = 1442f / 1005f, - imageUrl = "https://uploads7.wikiart.org/images/rene-magritte/the-treachery-of-images-this-is-not-a-pipe-1948(2).jpg", - title = "The treachery of images (This is not a pipe)" - ), - Artwork( - id = "11", - aspectRatio = 1616f / 2889f, - imageUrl = "https://uploads8.wikiart.org/images/francisco-goya/saturn-devouring-one-of-his-children-1823.jpg", - title = "Saturn Devouring One of His Sons" - ), - Artwork( - id = "12", - aspectRatio = 8533f / 4325f, - imageUrl = "https://uploads6.wikiart.org/images/hieronymus-bosch/the-garden-of-earthly-delights-1515-7.jpg", - title = "The Garden of Earthly Delights" - ), - Artwork( - id = "13", - aspectRatio = 1600f / 1067f, - imageUrl = "https://uploads5.wikiart.org/images/sandro-botticelli/the-birth-of-venus-1485(1).jpg", - title = "The Birth of Venus" - ), - Artwork( - id = "14", - aspectRatio = 1611f / 2000f, - imageUrl = "https://uploads1.wikart.org/images/edvard-munch/the-scream-1893(2).jpg", - title = "The Scream" - ), - Artwork( - id = "15", - aspectRatio = 2305f / 1845f, - imageUrl = "https://uploads6.wikiart.org/images/magdalena-carmen-frieda-kahlo-y-calderón-de-rivera/henry-ford-hospital-the-flying-bed-1932.jpg", - title = "Henry Ford Hospital (The Flying Bed)" - ), - Artwork( - id = "16", - aspectRatio = 520f / 600f, - imageUrl = "https://uploads5.wikiart.org/images/balthus/guitar-lesson-1934.jpg", - title = "Guitar lesson" - ), - Artwork( - id = "17", - aspectRatio = 5357f / 4009f, - imageUrl = "https://uploads3.wikiart.org/00144/images/jean-francois-millet/jean-fran-ois-millet-gleaners-google-art-project.jpg", - title = "The Gleaners" - ), - Artwork( - id = "18", - aspectRatio = 1930f / 2400f, - imageUrl = "https://uploads8.wikiart.org/00142/images/rembrandt/christ-in-the-storm.jpg", - title = "The Storm on the Sea of Galilee" - ), - Artwork( - id = "19", - aspectRatio = 640f / 479f, - imageUrl = "https://uploads3.wikiart.org/images/wassily-kandinsky/color-study-squares-with-concentric-circles-1913(1).jpg", - title = "Color Study: Squares with Concentric Circles" - ), - Artwork( - id = "20", - aspectRatio = 1697f / 1280f, - imageUrl = "https://uploads8.wikiart.org/images/octavio-ocampo/forever-always.jpg", - title = "Forever Always" - ), - Artwork( - id = "21", - aspectRatio = 640f / 475f, - imageUrl = "https://uploads3.wikiart.org/images/salvador-dali/hitler-masturbating.jpg", - title = "Hitler Masturbating" - ), - Artwork( - id = "22", - aspectRatio = 3648f / 5472f, - imageUrl = "https://uploads1.wikiart.org/00198/images/pablo-picasso/old-guitarist-chicago.jpg", - title = "The old blind guitarist" - ), - Artwork( - id = "23", - aspectRatio = 759f / 900f, - imageUrl = "https://uploads5.wikiart.org/00129/images/johannes-vermeer/the-girl-with-a-pearl-earring.jpg", - title = "The Girl with a Pearl Earring" - ), - Artwork( - id = "24", - aspectRatio = 592f / 843f, - imageUrl = "https://uploads2.wikiart.org/00180/images/leonardo-da-vinci/da-vinci-vitruve-luc-viatour.jpg", - title = "The proportions of the human figure (The Vitruvian Man)" - ), - Artwork( - id = "25", - aspectRatio = 1252f / 1624f, - imageUrl = "https://uploads2.wikiart.org/images/rene-magritte/son-of-man-1964(1).jpg", - title = "The Son of Man" - ), - Artwork( - id = "26", - aspectRatio = 3768f / 6214f, - imageUrl = "https://uploads4.wikiart.org/images/marcel-duchamp/nude-descending-a-staircase-no-2-1912.jpg", - title = "Nude Descending a Staircase, No.2" - ), - Artwork( - id = "27", - aspectRatio = 6000f / 3274f, - imageUrl = "https://uploads1.wikiart.org/00129/images/edward-hopper/nighthawks.jpg", - title = "Nighthawks" - ), - Artwork( - id = "28", - aspectRatio = 1400f / 997f, - imageUrl = "https://uploads5.wikiart.org/00475/images/raphael/1-xvkpn0qm3eiqpzivkggfea.jpg", - title = "The School of Athens" - ), - Artwork( - id = "29", - aspectRatio = 1197f / 1039f, - imageUrl = "https://uploads7.wikiart.org/images/magdalena-carmen-frieda-kahlo-y-calderón-de-rivera/my-birth-1932.jpg", - title = "My Birth" - ), - Artwork( - id = "30", - aspectRatio = 1980f / 3131f, - imageUrl = "https://uploads1.wikiart.org/00475/images/georges-seurat/eiffel-tower-c-1889-1.jpeg", - title = "The Eiffel Tower" - ), - Artwork( - id = "31", - aspectRatio = 2343f / 3000f, - imageUrl = "https://uploads8.wikiart.org/images/caspar-david-friedrich/the-wanderer-above-the-sea-of-fog.jpg", - title = "The Wanderer Above the Sea of Fog" - ), - Artwork( - id = "32", - aspectRatio = 1139f / 1437f, - imageUrl = "https://uploads5.wikiart.org/images/salvador-dali/dream-caused-by-the-flight-of-a-bee-around-a-pomegranate-one-second-before-awakening.jpg", - title = "Dream Caused by the Flight of a Bee around a Pomegranate a Second before Awakening" - ), - Artwork( - id = "33", - aspectRatio = 681f / 850f, - imageUrl = "https://uploads2.wikiart.org/images/claude-monet/women-in-the-garden.jpg", - title = "Women in the garden" - ), - Artwork( - id = "34", - aspectRatio = 660f / 495f, - imageUrl = "https://uploads2.wikiart.org/00304/images/andy-warhol/marilyn-diptych.jpg", - title = "Marilyn Diptych" - ), - Artwork( - id = "35", - aspectRatio = 804f / 1022f, - imageUrl = "https://uploads2.wikiart.org/images/pablo-picasso/self-portrait-1907.jpg", - title = "Self-Portrait" - ), - Artwork( - id = "36", - aspectRatio = 2023f / 1589f, - imageUrl = "https://uploads6.wikiart.org/00207/images/57726d84edc2cb3880b48a43/iv-n-el-terrible-y-su-hijo-por-ili-repin-2.jpg", - title = "Ivan the Terrible and His Son Ivan on November 16, 1581" - ), - Artwork( - id = "37", - aspectRatio = 1827f / 2160f, - imageUrl = "https://uploads4.wikiart.org/images/jean-michel-basquiat/head.jpg", - title = "Skull" - ), - Artwork( - id = "38", - aspectRatio = 500f / 342f, - imageUrl = "https://uploads0.wikiart.org/images/man-ray/minotaur-1934.jpg", - title = "Minotaur" - ), - Artwork( - id = "39", - aspectRatio = 2206f / 2186f, - imageUrl = "https://uploads4.wikiart.org/images/magdalena-carmen-frieda-kahlo-y-calderón-de-rivera/the-two-fridas-1939.jpg", - title = "The Two Fridas" - ), - Artwork( - id = "40", - aspectRatio = 1500f / 1198f, - imageUrl = "https://uploads6.wikiart.org/00129/images/eugene-delacroix/the-liberty-leading-the-people.jpg", - title = "Liberty Leading the People" - ) - ) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/ArtworkItems.kt b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/ArtworkItems.kt new file mode 100644 index 0000000..4f1cb25 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/ArtworkItems.kt @@ -0,0 +1,298 @@ +package com.vipulasri.aspecto.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest + +@Composable +internal fun ArtworkItem(item: Artwork) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalPlatformContext.current) + .data(item.imageUrl) + .build(), + contentDescription = item.title, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.45f)) + .padding(8.dp), + contentAlignment = Alignment.BottomStart + ) { + Text( + text = item.title, + color = Color.White, + fontWeight = FontWeight.Medium, + fontSize = 12.sp, + maxLines = 2, + overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis + ) + } + } +} + +internal fun getItems(): List { + return listOf( + Artwork( + id = "1", + aspectRatio = 2000f / 1446f, + imageUrl = "https://uploads0.wikiart.org/00475/images/salvador-dali/w1siziisijm4njq3mcjdlfsiccisimnvbnzlcnqilcitcxvhbgl0esa5mcatcmvzaxplidiwmdb4mjawmfx1mdazzsjdxq.jpg", + title = "The Persistence of Memory" + ), + Artwork( + id = "2", + aspectRatio = 1020f / 1500f, + imageUrl = "https://uploads8.wikiart.org/00339/images/leonardo-da-vinci/mona-lisa-c-1503-1519.jpg", + title = "Mona Lisa" + ), + Artwork( + id = "3", + aspectRatio = 2000f / 1594f, + imageUrl = "https://uploads3.wikiart.org/00142/images/vincent-van-gogh/the-starry-night.jpg", + title = "The Starry Night" + ), + Artwork( + id = "4", + aspectRatio = 5773f / 4478f, + imageUrl = "https://uploads8.wikiart.org/00129/images/claude-monet/impression-sunrise.jpg", + title = "Impression, sunrise" + ), + Artwork( + id = "5", + aspectRatio = 3369f / 1523f, + imageUrl = "https://uploads8.wikiart.org/00139/images/pablo-picasso/guernica-by-pablo-picasso.jpg", + title = "Guernica" + ), + Artwork( + id = "6", + aspectRatio = 3000f / 2190f, + imageUrl = "https://uploads1.wikiart.org/images/salvador-dali/the-great-masturbator-1929.jpg", + title = "The Great Masturbator" + ), + Artwork( + id = "7", + aspectRatio = 1280f / 812f, + imageUrl = "https://uploads0.wikiart.org/00242/images/alexandre-cabanel/fallen-angel-alexandre-cabanel.jpg", + title = "Fallen Angel" + ), + Artwork( + id = "8", + aspectRatio = 5000f / 5017f, + imageUrl = "https://uploads6.wikiart.org/00142/images/57726d7eedc2cb3880b47e13/the-kiss-gustav-klimt-google-cultural-institute.jpg", + title = "The Kiss" + ), + Artwork( + id = "9", + aspectRatio = 3524f / 1599f, + imageUrl = "https://uploads8.wikiart.org/00475/images/michelangelo/the-creation-of-adam-michelangelo-c-1512.jpg", + title = "Sistine Chapel Ceiling: Creation of Adam" + ), + Artwork( + id = "10", + aspectRatio = 1442f / 1005f, + imageUrl = "https://uploads7.wikiart.org/images/rene-magritte/the-treachery-of-images-this-is-not-a-pipe-1948(2).jpg", + title = "The treachery of images (This is not a pipe)" + ), + Artwork( + id = "11", + aspectRatio = 1616f / 2889f, + imageUrl = "https://uploads8.wikiart.org/images/francisco-goya/saturn-devouring-one-of-his-children-1823.jpg", + title = "Saturn Devouring One of His Sons" + ), + Artwork( + id = "12", + aspectRatio = 8533f / 4325f, + imageUrl = "https://uploads6.wikiart.org/images/hieronymus-bosch/the-garden-of-earthly-delights-1515-7.jpg", + title = "The Garden of Earthly Delights" + ), + Artwork( + id = "13", + aspectRatio = 1600f / 1067f, + imageUrl = "https://uploads5.wikiart.org/images/sandro-botticelli/the-birth-of-venus-1485(1).jpg", + title = "The Birth of Venus" + ), + Artwork( + id = "14", + aspectRatio = 1611f / 2000f, + imageUrl = "https://uploads1.wikart.org/images/edvard-munch/the-scream-1893(2).jpg", + title = "The Scream" + ), + Artwork( + id = "15", + aspectRatio = 2305f / 1845f, + imageUrl = "https://uploads6.wikiart.org/images/magdalena-carmen-frieda-kahlo-y-calderón-de-rivera/henry-ford-hospital-the-flying-bed-1932.jpg", + title = "Henry Ford Hospital (The Flying Bed)" + ), + Artwork( + id = "16", + aspectRatio = 520f / 600f, + imageUrl = "https://uploads5.wikiart.org/images/balthus/guitar-lesson-1934.jpg", + title = "Guitar lesson" + ), + Artwork( + id = "17", + aspectRatio = 5357f / 4009f, + imageUrl = "https://uploads3.wikiart.org/00144/images/jean-francois-millet/jean-fran-ois-millet-gleaners-google-art-project.jpg", + title = "The Gleaners" + ), + Artwork( + id = "18", + aspectRatio = 1930f / 2400f, + imageUrl = "https://uploads8.wikiart.org/00142/images/rembrandt/christ-in-the-storm.jpg", + title = "The Storm on the Sea of Galilee" + ), + Artwork( + id = "19", + aspectRatio = 640f / 479f, + imageUrl = "https://uploads3.wikiart.org/images/wassily-kandinsky/color-study-squares-with-concentric-circles-1913(1).jpg", + title = "Color Study: Squares with Concentric Circles" + ), + Artwork( + id = "20", + aspectRatio = 1697f / 1280f, + imageUrl = "https://uploads8.wikiart.org/images/octavio-ocampo/forever-always.jpg", + title = "Forever Always" + ), + Artwork( + id = "21", + aspectRatio = 640f / 475f, + imageUrl = "https://uploads3.wikiart.org/images/salvador-dali/hitler-masturbating.jpg", + title = "Hitler Masturbating" + ), + Artwork( + id = "22", + aspectRatio = 3648f / 5472f, + imageUrl = "https://uploads1.wikiart.org/00198/images/pablo-picasso/old-guitarist-chicago.jpg", + title = "The old blind guitarist" + ), + Artwork( + id = "23", + aspectRatio = 759f / 900f, + imageUrl = "https://uploads5.wikiart.org/00129/images/johannes-vermeer/the-girl-with-a-pearl-earring.jpg", + title = "The Girl with a Pearl Earring" + ), + Artwork( + id = "24", + aspectRatio = 592f / 843f, + imageUrl = "https://uploads2.wikiart.org/00180/images/leonardo-da-vinci/da-vinci-vitruve-luc-viatour.jpg", + title = "The proportions of the human figure (The Vitruvian Man)" + ), + Artwork( + id = "25", + aspectRatio = 1252f / 1624f, + imageUrl = "https://uploads2.wikiart.org/images/rene-magritte/son-of-man-1964(1).jpg", + title = "The Son of Man" + ), + Artwork( + id = "26", + aspectRatio = 3768f / 6214f, + imageUrl = "https://uploads4.wikiart.org/images/marcel-duchamp/nude-descending-a-staircase-no-2-1912.jpg", + title = "Nude Descending a Staircase, No.2" + ), + Artwork( + id = "27", + aspectRatio = 6000f / 3274f, + imageUrl = "https://uploads1.wikiart.org/00129/images/edward-hopper/nighthawks.jpg", + title = "Nighthawks" + ), + Artwork( + id = "28", + aspectRatio = 1400f / 997f, + imageUrl = "https://uploads5.wikiart.org/00475/images/raphael/1-xvkpn0qm3eiqpzivkggfea.jpg", + title = "The School of Athens" + ), + Artwork( + id = "29", + aspectRatio = 1197f / 1039f, + imageUrl = "https://uploads7.wikiart.org/images/magdalena-carmen-frieda-kahlo-y-calderón-de-rivera/my-birth-1932.jpg", + title = "My Birth" + ), + Artwork( + id = "30", + aspectRatio = 1980f / 3131f, + imageUrl = "https://uploads1.wikiart.org/00475/images/georges-seurat/eiffel-tower-c-1889-1.jpeg", + title = "The Eiffel Tower" + ), + Artwork( + id = "31", + aspectRatio = 2343f / 3000f, + imageUrl = "https://uploads8.wikiart.org/images/caspar-david-friedrich/the-wanderer-above-the-sea-of-fog.jpg", + title = "The Wanderer Above the Sea of Fog" + ), + Artwork( + id = "32", + aspectRatio = 1139f / 1437f, + imageUrl = "https://uploads5.wikiart.org/images/salvador-dali/dream-caused-by-the-flight-of-a-bee-around-a-pomegranate-one-second-before-awakening.jpg", + title = "Dream Caused by the Flight of a Bee around a Pomegranate a Second before Awakening" + ), + Artwork( + id = "33", + aspectRatio = 681f / 850f, + imageUrl = "https://uploads2.wikiart.org/images/claude-monet/women-in-the-garden.jpg", + title = "Women in the garden" + ), + Artwork( + id = "34", + aspectRatio = 660f / 495f, + imageUrl = "https://uploads2.wikiart.org/00304/images/andy-warhol/marilyn-diptych.jpg", + title = "Marilyn Diptych" + ), + Artwork( + id = "35", + aspectRatio = 804f / 1022f, + imageUrl = "https://uploads2.wikiart.org/images/pablo-picasso/self-portrait-1907.jpg", + title = "Self-Portrait" + ), + Artwork( + id = "36", + aspectRatio = 2023f / 1589f, + imageUrl = "https://uploads6.wikiart.org/00207/images/57726d84edc2cb3880b48a43/iv-n-el-terrible-y-su-hijo-por-ili-repin-2.jpg", + title = "Ivan the Terrible and His Son Ivan on November 16, 1581" + ), + Artwork( + id = "37", + aspectRatio = 1827f / 2160f, + imageUrl = "https://uploads4.wikiart.org/images/jean-michel-basquiat/head.jpg", + title = "Skull" + ), + Artwork( + id = "38", + aspectRatio = 500f / 342f, + imageUrl = "https://uploads0.wikiart.org/images/man-ray/minotaur-1934.jpg", + title = "Minotaur" + ), + Artwork( + id = "39", + aspectRatio = 2206f / 2186f, + imageUrl = "https://uploads4.wikiart.org/images/magdalena-carmen-frieda-kahlo-y-calderón-de-rivera/the-two-fridas-1939.jpg", + title = "The Two Fridas" + ), + Artwork( + id = "40", + aspectRatio = 1500f / 1198f, + imageUrl = "https://uploads6.wikiart.org/00129/images/eugene-delacroix/the-liberty-leading-the-people.jpg", + title = "Liberty Leading the People" + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/BasicGrid.kt b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/BasicGrid.kt new file mode 100644 index 0000000..2b0f7d8 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/BasicGrid.kt @@ -0,0 +1,131 @@ +package com.vipulasri.aspecto.sample + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.tooling.preview.Preview +import com.vipulasri.aspecto.AspectoGrid +import com.vipulasri.aspecto.sample.ui.theme.AspectoTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged + +private const val PAGE_SIZE = 20 +private const val MAX_PAGES = 6 +private const val APPEND_THRESHOLD_ROWS = 3 +private const val LOAD_DELAY_MS = 600L + +@Composable +fun BasicGrid(modifier: Modifier = Modifier) { + val state = rememberLazyListState() + var items by remember { mutableStateOf(getItems().take(PAGE_SIZE)) } + var currentPage by remember { mutableIntStateOf(1) } + var isAppending by remember { mutableStateOf(false) } + + LaunchedEffect(state) { + snapshotFlow { + val layoutInfo = state.layoutInfo + val total = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + total > 0 && lastVisible >= 0 && lastVisible >= total - APPEND_THRESHOLD_ROWS + } + .distinctUntilChanged() + .collect { shouldLoad -> + if (shouldLoad && !isAppending && currentPage < MAX_PAGES) { + isAppending = true + delay(LOAD_DELAY_MS) + currentPage += 1 + val newItems = getItems().take(PAGE_SIZE) + .map { it.copy(id = "append-${currentPage}-${it.id}") } + items += newItems + isAppending = false + } + } + } + + Box(modifier = modifier) { + AspectoGrid( + modifier = Modifier.fillMaxSize(), + state = state, + maxRowHeight = 250.dp, + itemPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), + contentPadding = PaddingValues(4.dp) + ) { + items( + items = items, + key = { it.id }, + aspectRatio = { it.aspectRatio } + ) { item -> + ArtworkItem(item = item) + } + } + + LoadingIndicator(visible = isAppending, currentPage = currentPage) + } +} + +@Composable +private fun BoxScope.LoadingIndicator(visible: Boolean, currentPage: Int) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Loading page ${currentPage + 1}...", + modifier = Modifier.padding(start = 12.dp), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp + ) + } + } +} + +@Preview(showBackground = true, widthDp = 400, heightDp = 800) +@Composable +private fun BasicGridPreview() { + AspectoTheme { + BasicGrid() + } +} diff --git a/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/DecoratedGrid.kt b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/DecoratedGrid.kt new file mode 100644 index 0000000..9d18791 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/vipulasri/aspecto/sample/DecoratedGrid.kt @@ -0,0 +1,193 @@ +package com.vipulasri.aspecto.sample + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vipulasri.aspecto.AspectoGrid +import com.vipulasri.aspecto.RowDecoration +import com.vipulasri.aspecto.sample.ui.theme.AspectoTheme +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged + +private const val PAGE_SIZE = 20 +private const val MAX_PAGES = 6 +private const val APPEND_THRESHOLD_ROWS = 3 +private const val LOAD_DELAY_MS = 600L +private const val AD_INTERVAL_ROWS = 3 + +@Composable +fun DecoratedGrid(modifier: Modifier = Modifier) { + val state = rememberLazyListState() + var items by remember { mutableStateOf(getItems().take(PAGE_SIZE)) } + var currentPage by remember { mutableIntStateOf(1) } + var isAppending by remember { mutableStateOf(false) } + + val decorations by remember(items.size) { + derivedStateOf { buildDecorations(items.size) } + } + + LaunchedEffect(state) { + snapshotFlow { + val layoutInfo = state.layoutInfo + val total = layoutInfo.totalItemsCount + val lastVisible = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + total > 0 && lastVisible >= 0 && lastVisible >= total - APPEND_THRESHOLD_ROWS + } + .distinctUntilChanged() + .collect { shouldLoad -> + if (shouldLoad && !isAppending && currentPage < MAX_PAGES) { + isAppending = true + delay(LOAD_DELAY_MS) + currentPage += 1 + val newItems = getItems().take(PAGE_SIZE) + .map { it.copy(id = "append-${currentPage}-${it.id}") } + items += newItems + isAppending = false + } + } + } + + Box(modifier = modifier) { + AspectoGrid( + modifier = Modifier.fillMaxSize(), + state = state, + maxRowHeight = 250.dp, + itemPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), + contentPadding = PaddingValues(4.dp), + decorations = decorations + ) { + items( + items = items, + key = { it.id }, + aspectRatio = { it.aspectRatio } + ) { item -> + ArtworkItem(item = item) + } + } + + LoadingIndicator(visible = isAppending, currentPage = currentPage) + } +} + +private fun buildDecorations(itemCount: Int): List { + val decorations = mutableListOf() + + decorations.add( + RowDecoration(index = 0, key = "header") { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Gallery Collection", + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + } + } + ) + + val estimatedRows = itemCount / 3 + var row = AD_INTERVAL_ROWS + while (row < estimatedRows) { + val adRow = row + decorations.add( + RowDecoration( + index = adRow, + key = "ad-$adRow", + contentType = "ad" + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Advertisement (row $adRow)", + color = MaterialTheme.colorScheme.onPrimaryContainer, + fontWeight = FontWeight.Medium + ) + } + } + ) + row += AD_INTERVAL_ROWS + } + + return decorations +} + +@Composable +private fun BoxScope.LoadingIndicator(visible: Boolean, currentPage: Int) { + AnimatedVisibility( + visible = visible, + enter = slideInVertically { it } + fadeIn(), + exit = slideOutVertically { it } + fadeOut(), + modifier = Modifier.align(Alignment.BottomCenter) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = "Loading page ${currentPage + 1}...", + modifier = Modifier.padding(start = 12.dp), + color = MaterialTheme.colorScheme.onSurface, + fontSize = 14.sp + ) + } + } +} + +@Preview(showBackground = true, widthDp = 400, heightDp = 800) +@Composable +private fun DecoratedGridPreview() { + AspectoTheme { + DecoratedGrid() + } +} From 4fafaa566fd20bf073baf9feca0237379a0db242 Mon Sep 17 00:00:00 2001 From: vipulasri Date: Sat, 22 Aug 2026 14:54:28 -0400 Subject: [PATCH 9/9] fix: add toolingPreview dependency to androidApp --- androidApp/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index c094fa4..b3fa236 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(libs.androidx.compose.foundation) implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.toolingPreview) implementation(libs.ktor.client.cio) debugImplementation(libs.androidx.compose.tooling) }