diff --git a/AGENTS.md b/AGENTS.md index e5a4831..18ad777 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,8 +116,9 @@ git push origin v1.1.0 - **JaCoCo**: 코드 커버리지 측정 (`./gradlew :library:jacocoTestReport`) + 임계값 검증 80% (`./gradlew :library:jacocoCoverageVerification`) - **Android Lint**: 정적 분석 (`./gradlew :library:lint`) - **Dokka 2.0**: API 문서 생성 (`./gradlew :library:dokkaGenerate`) -- **ProGuard Consumer Rules**: `library/consumer-rules.pro`에 public API 보호 규칙 정의 +- **ProGuard Consumer Rules**: `library/consumer-rules.pro`에 public API 보호 규칙과 패키지 범위 Compose keep 규칙 정의 - **성능 벤치마크**: `GridBenchmarkTest.kt`에서 대량 데이터(1000~3650일) 성능 검증 +- **Compose Compiler Reports**: 필요 시 `-PcomposeCompilerReports=true`로 metrics/reports 생성 - **CHANGELOG**: `CHANGELOG.md`에 Keep a Changelog 형식으로 변경 이력 관리 ## 라이브러리 소스 구조 @@ -125,7 +126,7 @@ git push origin v1.1.0 - `library/src/main/java/com/inseong/gitgrass/` - `GitGrass.kt` — 메인 컴포저블 (public API) - `GitGrassColors.kt` — 색상 스킴 데이터 클래스 - - `GitGrassComponents.kt` — 내부 UI 컴포넌트 (YearLabel, MonthRow, WeekLabelColumn, GrassGridContent, GrassWeekColumn, GrassCell, StreakSummary, ColorLegend) + - `GitGrassComponents.kt` — 내부 UI 컴포넌트 (YearLabel, MonthRow, WeekLabelColumn, GrassGridContent, Canvas 기반 GrassWeekColumn, GrassCell hit target, StreakSummary, ColorLegend) - `GitGrassDefaults.kt` — 기본값 및 팩토리 (색상, 라벨, 크기, 로케일, 레이아웃 상수) - `GridUtils.kt` — 순수 함수 유틸리티 (normalizeDateRange, normalizeContributions, generateDayList, buildGrid, dayIndexInWeek, weekDaysOrdered, createMonthLabels, formatYearLabel, calculateStreak) - `RenderData.kt` — 셀별 count/color/접근성 라벨을 미리 계산하는 렌더링 데이터 유틸리티 @@ -138,7 +139,7 @@ git push origin v1.1.0 - `GitGrassDefaultsTest.kt` — startDate, endDate, levelThresholds, 로케일 라벨, 색상 - `LevelToColorTest.kt` — 색상 매핑 (8개) - `RenderDataTest.kt` — 렌더링 데이터 사전 계산 검증 - - `GridBenchmarkTest.kt` — 성능 벤치마크 (5개) + - `GridBenchmarkTest.kt` — 성능 벤치마크 (6개) - `library/src/androidTest/` — Compose UI 인스트루먼트 테스트 - `GrassCellTest.kt` — 셀 클릭/롱클릭 콜백 검증 - `LabelRenderingTest.kt` — 월 라벨, 주 라벨 렌더링 검증 @@ -169,6 +170,13 @@ git push origin v1.1.0 - 모든 작업이 완료되면 반드시 git commit 후 `git push`까지 수행한다. - 커밋 메시지는 한국어로, conventional commit 형식을 따른다 (예: `feat:`, `fix:`, `refactor:`, `docs:`). +### 5. PR 생성 규칙 +- Branch Protection 때문에 `main`에 직접 push하지 않고 작업 브랜치를 push한 뒤 PR을 생성한다. +- PR은 기본적으로 **Ready for review** 상태로 생성한다. +- 사용자가 명시적으로 요청하지 않는 한 Draft PR로 생성하지 않는다. +- `gh pr create` 사용 시 `--draft` 옵션을 넣지 않는다. 실수로 Draft PR을 만들었다면 즉시 `gh pr ready`로 전환한다. +- PR 본문에는 변경 요약, 문서 업데이트 여부, 실행한 테스트 결과, public API 영향 여부를 포함한다. + ### 커밋 메시지 형식 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 3944d5d..c7aaf7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ - 셀/범례 렌더링에서 Shape 재사용 및 불필요한 clip 제거로 반복 셀 할당 비용 감소 - 셀별 count, color, 접근성 라벨을 렌더링 전용 데이터로 미리 계산해 리컴포지션 중 반복 계산 감소 - 샘플 앱에서 날짜 범위, 로케일 라벨, 커스텀 색상 팔레트를 `remember`로 재사용하도록 개선 +- 그리드와 월 라벨 행을 주 단위 `LazyRow`로 전환해 긴 날짜 범위에서 화면 밖 UI 구성 비용 감소 +- 셀 색상 렌더링을 내부 Canvas 경로로 전환하고, 접근성/클릭/롱클릭은 투명 hit target overlay로 유지 +- ProGuard consumer rules의 Compose/Immutable keep 범위를 라이브러리 패키지로 축소 +- `-PcomposeCompilerReports=true` 사용 시 Compose compiler metrics/reports를 생성하도록 opt-in 설정 추가 +- Lazy/Canvas 렌더링 데이터 준비 경로의 10년 범위 성능 벤치마크 추가 +- PR을 Draft가 아닌 Ready for review 상태로 생성하도록 에이전트 작업 문서에 명시 ## [1.1.0] - 2026-03-13 diff --git a/CLAUDE.md b/CLAUDE.md index ba56d5e..b288c68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,7 @@ - 개발 작업 시작 전 계획 수립(Plan Mode First) - 구현 시 테스트 가능성 우선(순수 함수/관심사 분리/작은 단위) - 작업 완료 시 커밋 + 푸시 +- PR 생성 시 Draft가 아닌 Ready for review 상태로 생성 - 커밋 메시지는 한국어 Conventional Commit 형식 사용 세부 규칙은 `AGENTS.md`의 Workflow Rules를 확인한다. diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index 03879f0..719eaf9 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -57,11 +57,11 @@ fun GitGrass(...) { val grid = remember(days, weekStartDay) { buildGrid(days, weekStartDay) } // 3. Compose 상태 - val scrollState = rememberScrollState() + val scrollState = rememberLazyListState() // 4. 부수 효과 (LaunchedEffect) LaunchedEffect(grid) { - scrollState.scrollTo(scrollState.maxValue) + // 최신 날짜가 보이도록 lazy list 위치 조정 } // 5. 레이아웃 구성 @@ -156,10 +156,10 @@ UI 렌더링 (Composable + remember) ``` GitGrass (루트 조율자) ├── YearLabel -├── MonthRow (scrollState 공유) +├── MonthRow (LazyListState 공유) ├── WeekLabelColumn -├── GrassGridContent (scrollState 공유) -│ └── GrassWeekColumn → GrassCell +├── GrassGridContent (LazyListState 공유) +│ └── GrassWeekColumn (Canvas) → GrassCell (hit target) ├── StreakSummary └── ColorLegend ``` @@ -285,11 +285,28 @@ val renderGrid = remember(grid, safeContributions, colors, levelOf) { 정규화가 필요 없는 입력 컬렉션은 원본을 그대로 반환한다. 값 변경이 실제로 필요한 경우에만 새 컬렉션을 만든다. +### Lazy 렌더링 우선 + +주 단위처럼 반복 개수가 날짜 범위에 비례하는 UI는 eager `Row`보다 `LazyRow`를 우선한다. 월 라벨과 그리드는 같은 `LazyListState`를 공유해 정렬과 스크롤 동기화를 유지한다. + +### Canvas와 Semantics 분리 + +반복 셀의 시각 렌더링은 Canvas로 합치되, 접근성/클릭/롱클릭은 별도의 hit target 컴포저블로 유지한다. 사용자가 렌더러를 직접 선택하는 public 옵션은 실제 요구가 생기기 전까지 추가하지 않는다. + +### Consumer Rules 범위 제한 + +AAR consumer ProGuard 규칙은 앱 전체에 영향을 주므로 `com.inseong.gitgrass.**`처럼 라이브러리 패키지 범위로 한정한다. 전역 `class *` keep 규칙은 피한다. + +### Compose Compiler 리포트 + +Compose compiler metrics/reports는 기본 빌드에 영향을 주지 않도록 Gradle property로 opt-in한다. 필요할 때만 `-PcomposeCompilerReports=true`로 생성한다. + ### 벤치마크 기준 | 연산 | 데이터 규모 | 제한 | |------|------------|------| | 단일 연산 (generateDayList, buildGrid, buildRenderGrid, calculateStreak) | 1,000일 | 100회 평균 < 100ms | +| Lazy/Canvas 렌더링 데이터 준비 (buildRenderGrid) | 3,650일 (10년) | 30회 평균 < 250ms | | 전체 파이프라인 | 3,650일 (10년) | 10회 평균 < 500ms | 새로운 유틸리티 함수 추가 시 대량 데이터 벤치마크를 `GridBenchmarkTest.kt`에 추가한다. @@ -575,8 +592,8 @@ fun GitGrass(...) { // O: 컴포지션 — 작은 컴포저블 조합 GitGrass (루트 조율자) ├── YearLabel // 독립 컴포넌트 -├── MonthRow // scrollState를 파라미터로 주입 -├── GrassGridContent // 데이터를 파라미터로 주입 +├── MonthRow // LazyListState를 파라미터로 주입 +├── GrassGridContent // 렌더링 데이터와 LazyListState를 파라미터로 주입 └── ColorLegend // 독립 컴포넌트 // X: 상속 기반 접근 diff --git a/README.md b/README.md index d60976b..f1ed816 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ GitGrass( 대부분의 1년 단위 그래프는 기본 설정만으로 충분히 가볍게 동작합니다. 여러 개의 그래프를 한 화면에 렌더링하거나 5년 이상의 긴 범위를 표시한다면 아래 원칙을 권장합니다: +- 별도 `renderMode`를 고를 필요는 없습니다. 내부에서 주 단위 lazy 렌더링과 Canvas 기반 셀 그리기를 자동으로 사용합니다. - `contributions`, `startDate`, `endDate`, 로케일 라벨, 커스텀 색상 팔레트는 `remember`나 ViewModel 상태로 안정적으로 전달하세요. - 필요 없는 보조 UI는 `showMonthLabels`, `showStreak`, `showLegend`를 `false`로 꺼두면 해당 계산과 렌더링 비용을 줄일 수 있습니다. - 매 리컴포지션마다 `LocalDate.now()`, `localizedMonthLabels()`, `GitGrassColors(...)`를 새로 만들기보다 한 번 계산한 값을 재사용하세요. @@ -261,7 +262,8 @@ GitGrass( - **Pure Compose Foundation** - Material3 의존 없이 Foundation의 `BasicText`만 사용하여, 어떤 디자인 시스템을 쓰는 프로젝트에서도 충돌 없이 동작합니다. - **유연한 색상 레벨** - 고정된 `level1`~`level4` 대신 `levels: List`를 사용합니다. 3단계든, 5단계든, 10단계든 자유롭게 지정할 수 있습니다. - **픽셀 단위 월 라벨 정렬** - 월 라벨이 그리드와 동일한 `Arrangement.spacedBy` + 고정 너비 슬롯 구조를 사용하여, 어떤 셀 크기에서도 정확하게 정렬됩니다. -- **공유 ScrollState** - 월 라벨 행과 그리드가 하나의 `ScrollState`를 공유하여 가로 스크롤 시 항상 동기화됩니다. +- **자동 최적화 렌더링** - 긴 날짜 범위에서는 주 단위 `LazyRow`가 화면에 필요한 열만 구성하고, 셀 색상은 Canvas로 그려 반복 노드 비용을 줄입니다. +- **공유 LazyListState** - 월 라벨 행과 그리드가 하나의 `LazyListState`를 공유하여 가로 스크롤 시 항상 동기화됩니다. - **순수 함수 & 테스트** - 그리드/스트릭 계산 로직이 부수효과 없는 순수 함수로 구현되어 있으며, 60+ 유닛 테스트와 Compose UI 테스트가 그리드 생성, 스트릭 계산, 색상 매핑, 엣지 케이스, UI 인터랙션을 검증합니다. - **접근성** - 모든 셀에 contentDescription, semantics 적용으로 스크린 리더를 지원합니다. - **오늘 날짜로 자동 스크롤** - `LaunchedEffect`로 첫 컴포지션 시 가장 최근 날짜(오른쪽 끝)로 자동 스크롤됩니다. diff --git a/docs/DOCS_CLASSIFICATION.md b/docs/DOCS_CLASSIFICATION.md index 3de949d..16933f5 100644 --- a/docs/DOCS_CLASSIFICATION.md +++ b/docs/DOCS_CLASSIFICATION.md @@ -16,7 +16,7 @@ ### AGENTS.md - 대상: AGENTS.md를 우선 읽는 자동화/에이전트 작업자 -- 역할: 작업 규칙, 커밋 규칙, 문서 인덱스 및 작업 기록 +- 역할: 작업 규칙, 커밋/PR 규칙, 문서 인덱스 및 작업 기록 - 성격: 내부 작업 지침 + 기록 허브 ### CODE_QUALITY.md @@ -49,6 +49,7 @@ - Public API 변경: `README.md`, `CHANGELOG.md`, 필요 시 `CODE_QUALITY.md` - 빌드/테스트/배포 파이프라인 변경: `AGENTS.md`, `CLAUDE.md` +- 브랜치/커밋/PR 운영 규칙 변경: `AGENTS.md`, 요약 반영(`CLAUDE.md`) - 코드 작성 규칙/리뷰 기준 변경: `CODE_QUALITY.md`, 요약 반영(`AGENTS.md`, `CLAUDE.md`) - 릴리즈 태그/버전 변경: `CHANGELOG.md`, `README.md` 버전 표기 diff --git a/library/build.gradle.kts b/library/build.gradle.kts index ad5bf52..85546b1 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -26,6 +26,17 @@ android { } } +composeCompiler { + val reportsEnabled = providers.gradleProperty("composeCompilerReports") + .map { it.equals("true", ignoreCase = true) } + .getOrElse(false) + + if (reportsEnabled) { + reportsDestination = layout.buildDirectory.dir("compose_compiler/reports") + metricsDestination = layout.buildDirectory.dir("compose_compiler/metrics") + } +} + jacoco { toolVersion = "0.8.12" } diff --git a/library/consumer-rules.pro b/library/consumer-rules.pro index 3ba5e57..30e2d2e 100644 --- a/library/consumer-rules.pro +++ b/library/consumer-rules.pro @@ -15,10 +15,10 @@ public ; } -# Compose @Composable 어노테이션 메서드 보호 --keepclassmembers class * { +# Compose @Composable 어노테이션 메서드 보호 (라이브러리 패키지로 한정) +-keepclassmembers class com.inseong.gitgrass.** { @androidx.compose.runtime.Composable ; } -# @Immutable 어노테이션 클래스 보호 (Compose 안정성 추론) --keep @androidx.compose.runtime.Immutable class * { *; } +# @Immutable 어노테이션 클래스 보호 (Compose 안정성 추론, 라이브러리 패키지로 한정) +-keep @androidx.compose.runtime.Immutable class com.inseong.gitgrass.** { *; } diff --git a/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt b/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt index 99b9acb..cbf9df4 100644 --- a/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt +++ b/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt @@ -1,6 +1,5 @@ package com.inseong.gitgrass -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription @@ -24,9 +23,7 @@ class GrassCellTest { composeTestRule.setContent { GrassCell( - color = GitGrassDefaults.colors().levels[0], size = GitGrassDefaults.cellSize, - shape = RoundedCornerShape(GitGrassDefaults.cellCornerRadius), contentDescriptionText = "$date: $count", clickLabelText = "$date details", onClick = { clicked = true }, @@ -47,9 +44,7 @@ class GrassCellTest { composeTestRule.setContent { GrassCell( - color = GitGrassDefaults.colors().levels[0], size = GitGrassDefaults.cellSize, - shape = RoundedCornerShape(GitGrassDefaults.cellCornerRadius), contentDescriptionText = "$date: $count", clickLabelText = "$date details", onClick = {}, @@ -72,9 +67,7 @@ class GrassCellTest { composeTestRule.setContent { GrassCell( - color = GitGrassDefaults.colors().empty, size = GitGrassDefaults.cellSize, - shape = RoundedCornerShape(GitGrassDefaults.cellCornerRadius), contentDescriptionText = "$date: $count", clickLabelText = "$date details", onClick = null, @@ -84,4 +77,25 @@ class GrassCellTest { composeTestRule.onNodeWithContentDescription("$date: $count") .assertExists() } + + @Test + fun gitGrass_canvasBackedCells_keepAccessibilitySemantics() { + val date = LocalDate.of(2025, 6, 15) + val count = 7 + + composeTestRule.setContent { + GitGrass( + contributions = mapOf(date to count), + startDate = date, + endDate = date, + showYearLabel = false, + showWeekLabels = false, + showMonthLabels = false, + showLegend = false, + ) + } + + composeTestRule.onNodeWithContentDescription("$date: $count") + .assertExists() + } } diff --git a/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt b/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt index 41abf89..fba23c5 100644 --- a/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt +++ b/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt @@ -1,6 +1,6 @@ package com.inseong.gitgrass -import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText @@ -31,7 +31,7 @@ class LabelRenderingTest { cellSpacing = GitGrassDefaults.cellSpacing, fontSize = GitGrassDefaults.labelFontSize, textColor = GitGrassDefaults.colors().text, - scrollState = ScrollState(0), + scrollState = rememberLazyListState(), weekLabelWidth = GitGrassDefaults.weekLabelWidth, ) } diff --git a/library/src/main/java/com/inseong/gitgrass/GitGrass.kt b/library/src/main/java/com/inseong/gitgrass/GitGrass.kt index 24543c7..5e69b33 100644 --- a/library/src/main/java/com/inseong/gitgrass/GitGrass.kt +++ b/library/src/main/java/com/inseong/gitgrass/GitGrass.kt @@ -1,20 +1,21 @@ package com.inseong.gitgrass -import androidx.compose.foundation.ScrollState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.Dp @@ -22,6 +23,8 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import java.time.DayOfWeek import java.time.LocalDate +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first /** * Streak information calculated from contribution data. @@ -145,10 +148,28 @@ fun GitGrass( ) } - val scrollState: ScrollState = rememberScrollState() + val scrollState = rememberLazyListState() + val density = LocalDensity.current - LaunchedEffect(grid) { - scrollState.scrollTo(scrollState.maxValue) + LaunchedEffect(safeStart, safeEnd, renderGrid.size, cellSize, cellSpacing, density) { + if (renderGrid.isEmpty()) return@LaunchedEffect + + val viewportWidth = snapshotFlow { scrollState.layoutInfo.viewportSize.width } + .filter { it > 0 } + .first() + val cellSizePx = with(density) { cellSize.roundToPx() } + val cellSpacingPx = with(density) { cellSpacing.roundToPx() } + val totalGridWidth = + (renderGrid.size * cellSizePx) + ((renderGrid.size - 1).coerceAtLeast(0) * cellSpacingPx) + + if (totalGridWidth > viewportWidth) { + scrollState.scrollToItem( + index = renderGrid.lastIndex, + scrollOffset = cellSizePx - viewportWidth, + ) + } else { + scrollState.scrollToItem(0) + } } val weekLabelWidth = if (showWeekLabels) GitGrassDefaults.weekLabelWidth else 0.dp @@ -198,7 +219,7 @@ fun GitGrass( renderGrid = renderGrid, cellSize = cellSize, cellSpacing = cellSpacing, - cellShape = cellShape, + cellCornerRadius = cellCornerRadius, scrollState = scrollState, onCellClick = onCellClick, onCellLongClick = onCellLongClick, diff --git a/library/src/main/java/com/inseong/gitgrass/GitGrassComponents.kt b/library/src/main/java/com/inseong/gitgrass/GitGrassComponents.kt index 4d6bfb6..dc68cc6 100644 --- a/library/src/main/java/com/inseong/gitgrass/GitGrassComponents.kt +++ b/library/src/main/java/com/inseong/gitgrass/GitGrassComponents.kt @@ -1,10 +1,9 @@ package com.inseong.gitgrass +import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,11 +13,16 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.semantics.Role @@ -66,7 +70,7 @@ internal fun MonthRow( cellSpacing: Dp, fontSize: TextUnit, textColor: Color, - scrollState: ScrollState, + scrollState: LazyListState, weekLabelWidth: Dp, ) { val labelMap = remember(monthPositions) { @@ -78,11 +82,16 @@ internal fun MonthRow( Spacer(modifier = Modifier.width(weekLabelWidth)) } - Row( - modifier = Modifier.horizontalScroll(scrollState, enabled = false), + LazyRow( + state = scrollState, + userScrollEnabled = false, horizontalArrangement = Arrangement.spacedBy(cellSpacing), ) { - for (weekIndex in 0 until weekCount) { + items( + count = weekCount, + key = { weekIndex -> weekIndex }, + contentType = { "month-label-slot" }, + ) { weekIndex -> Box(modifier = Modifier.width(cellSize).wrapContentSize(unbounded = true, align = Alignment.CenterStart)) { val monthNumber = labelMap[weekIndex] if (monthNumber != null) { @@ -140,21 +149,25 @@ internal fun GrassGridContent( renderGrid: RenderGrid, cellSize: Dp, cellSpacing: Dp, - cellShape: Shape, - scrollState: ScrollState, + cellCornerRadius: Dp, + scrollState: LazyListState, onCellClick: ((LocalDate, Int) -> Unit)?, onCellLongClick: ((LocalDate, Int) -> Unit)?, ) { - Row( - modifier = Modifier.horizontalScroll(scrollState), + LazyRow( + state = scrollState, horizontalArrangement = Arrangement.spacedBy(cellSpacing), ) { - for (week in renderGrid) { + items( + count = renderGrid.size, + key = { weekIndex -> weekIndex }, + contentType = { "grass-week" }, + ) { weekIndex -> GrassWeekColumn( - week = week, + week = renderGrid[weekIndex], cellSize = cellSize, cellSpacing = cellSpacing, - cellShape = cellShape, + cellCornerRadius = cellCornerRadius, onCellClick = onCellClick, onCellLongClick = onCellLongClick, ) @@ -168,31 +181,56 @@ internal fun GrassWeekColumn( week: List, cellSize: Dp, cellSpacing: Dp, - cellShape: Shape, + cellCornerRadius: Dp, onCellClick: ((LocalDate, Int) -> Unit)?, onCellLongClick: ((LocalDate, Int) -> Unit)?, ) { - Column(verticalArrangement = Arrangement.spacedBy(cellSpacing)) { - for (cell in week) { - if (cell != null) { - GrassCell( - color = cell.color, - size = cellSize, - shape = cellShape, - contentDescriptionText = cell.contentDescription, - clickLabelText = cell.clickLabel, - onClick = onCellClick?.let { callback -> { callback(cell.date, cell.count) } }, - onLongClick = onCellLongClick?.let { callback -> { callback(cell.date, cell.count) } }, - ) - } else { - Spacer(modifier = Modifier.size(cellSize)) + val weekHeight = (cellSize * DAYS_PER_WEEK) + (cellSpacing * (DAYS_PER_WEEK - 1)) + + Box(modifier = Modifier.width(cellSize).height(weekHeight)) { + Canvas(modifier = Modifier.matchParentSize()) { + val cellSizePx = cellSize.toPx() + val cellSpacingPx = cellSpacing.toPx() + val cornerRadiusPx = cellCornerRadius.toPx() + + for (index in 0 until DAYS_PER_WEEK) { + val cell = week.getOrNull(index) + if (cell != null) { + val top = index.toFloat() * (cellSizePx + cellSpacingPx) + drawRoundRect( + color = cell.color, + topLeft = Offset(0f, top), + size = Size(cellSizePx, cellSizePx), + cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx), + ) + } + } + } + + Column( + modifier = Modifier.matchParentSize(), + verticalArrangement = Arrangement.spacedBy(cellSpacing), + ) { + for (index in 0 until DAYS_PER_WEEK) { + val cell = week.getOrNull(index) + if (cell != null) { + GrassCell( + size = cellSize, + contentDescriptionText = cell.contentDescription, + clickLabelText = cell.clickLabel, + onClick = onCellClick?.let { callback -> { callback(cell.date, cell.count) } }, + onLongClick = onCellLongClick?.let { callback -> { callback(cell.date, cell.count) } }, + ) + } else { + Spacer(modifier = Modifier.size(cellSize)) + } } } } } /** - * Single rounded-rectangle cell representing one day. + * Transparent per-day hit target layered above the Canvas-rendered cell. * * Includes accessibility semantics with content description * for screen reader support. @@ -200,26 +238,24 @@ internal fun GrassWeekColumn( @OptIn(ExperimentalFoundationApi::class) @Composable internal fun GrassCell( - color: Color, size: Dp, - shape: Shape, contentDescriptionText: String, clickLabelText: String?, onClick: (() -> Unit)?, onLongClick: (() -> Unit)? = null, ) { + val hasActions = onClick != null || onLongClick != null val baseModifier = Modifier .size(size) - .background(color, shape) .semantics { contentDescription = contentDescriptionText - if (onClick != null) { + if (hasActions) { role = Role.Button } } Box( - modifier = if (onClick != null || onLongClick != null) { + modifier = if (hasActions) { baseModifier.combinedClickable( onClick = onClick ?: {}, onLongClick = onLongClick, diff --git a/library/src/test/java/com/inseong/gitgrass/GridBenchmarkTest.kt b/library/src/test/java/com/inseong/gitgrass/GridBenchmarkTest.kt index bcdd002..e0f649d 100644 --- a/library/src/test/java/com/inseong/gitgrass/GridBenchmarkTest.kt +++ b/library/src/test/java/com/inseong/gitgrass/GridBenchmarkTest.kt @@ -111,6 +111,44 @@ class GridBenchmarkTest { assert(avgMs < 100) { "buildRenderGrid 성능 초과: $avgMs ms" } } + @Test + fun `buildRenderGrid 3650일 Lazy Canvas 전처리 250ms 이내 완료`() { + val (start, contributions) = generateContributions(3650) + val end = LocalDate.of(2025, 12, 31) + val days = generateDayList(start, end) + val grid = buildGrid(days) + val colors = GitGrassDefaults.colors() + + repeat(3) { + buildRenderGrid( + grid = grid, + contributions = contributions, + colors = colors, + levelOf = GitGrassDefaults.levelThresholds, + cellContentDescription = { day, count -> "$day: $count" }, + cellClickLabel = { day -> "$day details" }, + includeClickLabels = false, + ) + } + + val elapsed = measureNanoTime { + repeat(30) { + buildRenderGrid( + grid = grid, + contributions = contributions, + colors = colors, + levelOf = GitGrassDefaults.levelThresholds, + cellContentDescription = { day, count -> "$day: $count" }, + cellClickLabel = { day -> "$day details" }, + includeClickLabels = false, + ) + } + } + val avgMs = elapsed / 1_000_000.0 / 30 + println("buildRenderGrid(3650일, Lazy/Canvas 전처리) 평균: %.3f ms".format(avgMs)) + assert(avgMs < 250) { "buildRenderGrid 10년 전처리 성능 초과: $avgMs ms" } + } + @Test fun `전체 파이프라인 3650일(10년) 500ms 이내 완료`() { val end = LocalDate.of(2025, 12, 31)