diff --git a/AGENTS.md b/AGENTS.md index 00b3ade..85b5048 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Uses [vanniktech/gradle-maven-publish-plugin](https://github.com/vanniktech/grad Maven coordinates: `io.github.ois0886:compose-git-grass:` -Current release: `1.1.1` +Current release: `1.2.0` ### 로컬 배포 (수동) @@ -129,7 +129,8 @@ git push origin v - `library/src/main/java/com/inseong/gitgrass/` - `GitGrass.kt` — 메인 컴포저블 (public API) - `GitGrassColors.kt` — 색상 스킴 데이터 클래스 - - `GitGrassComponents.kt` — 내부 UI 컴포넌트 (YearLabel, MonthRow, WeekLabelColumn, GrassGridContent, Canvas 기반 GrassWeekColumn, GrassCell hit target, StreakSummary, ColorLegend) + - `GitGrassSelection.kt` — 제어형 선택 상태 public API와 선택값 정규화 + - `GitGrassComponents.kt` — 내부 UI 컴포넌트 (YearLabel, MonthLabelSlot, WeekLabelColumn, 단일 LazyRow 기반 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/접근성 라벨을 미리 계산하는 렌더링 데이터 유틸리티 @@ -144,8 +145,8 @@ git push origin v - `RenderDataTest.kt` — 렌더링 데이터 사전 계산 검증 - `GridBenchmarkTest.kt` — 성능 벤치마크 (6개) - `library/src/androidTest/` — Compose UI 인스트루먼트 테스트 - - `GrassCellTest.kt` — 셀 클릭/롱클릭 콜백 검증 - - `LabelRenderingTest.kt` — 월 라벨, 주 라벨 렌더링 검증 + - `GrassCellTest.kt` — 셀 클릭/롱클릭, 선택 semantics 검증 + - `LabelRenderingTest.kt` — 단일 LazyRow 월 라벨, 주 시작일별 라벨 렌더링 검증 ## Code Quality @@ -226,3 +227,4 @@ docs: AGENTS.md 워크플로우 규칙 추가 - 2026-04-10: CI `ui-test` 실패 원인(`GrassCellTest`의 `performLongClick` 참조 오류) 수정 후, GitHub Actions 런 #24225465922 전체 잡 통과를 확인함. - 2026-04-10: CI `ui-test` 안정성 강화를 위해 잡 타임아웃(30분)과 에뮬레이터 `-no-metrics` 옵션을 추가함. - 2026-06-11: `v1.1.1` 릴리즈 기준으로 README/CHANGELOG/AGENTS/CLAUDE 문서의 버전 및 릴리즈 절차 표기를 재점검함. +- 2026-07-15: `v1.2.0` 릴리즈 기준으로 제어형 선택 API, 단일 LazyRow 렌더링, 샘플 쇼케이스와 README 라이트/다크 이미지를 반영함. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8360f43..401f71a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,27 @@ ## [Unreleased] +## [1.2.0] - 2026-07-15 + +### Added +- `GitGrassSelection`: 날짜, 인셋 아웃라인 색상/두께를 담는 제어형 선택 상태 API 추가 +- `GitGrass.selection`: 앱 상태로 선택 셀을 강조하고 `selected` semantics를 노출하는 옵션 추가 +- `GitGrassDefaults.weekLabelsFor()`: 주 시작일에 맞춰 캐시된 영어 요일 라벨을 반환하는 헬퍼 추가 +- 선택 해석, 월 경계, 완전한 주 단위 초기 스크롤 및 샘플 통계 테스트 추가 + ### Changed -- `1.1.1` 릴리즈 기준으로 README/AGENTS/CLAUDE 문서의 현재 버전 표기와 릴리즈 절차 안내를 정리 +- 월 라벨과 주별 Canvas를 하나의 `LazyRow` 항목으로 통합해 스크롤 동기화 구조 단순화 +- 월 라벨을 매월 1일이 속한 주에 배치하고 첫 주 충돌 시 새 월을 우선하도록 개선 +- 최신 날짜 자동 스크롤을 월 라벨이 잘리지 않는 완전한 주 열 기준으로 변경 +- `weekStartDay` 변경 시 기본 영어 요일 라벨도 같은 순서로 자동 정렬 +- 선택 변경 시 render grid를 재계산하지 않고 보이는 Canvas와 semantics만 갱신 +- 샘플 앱을 GitHub 라이트/다크 대표 데모와 `Layout`/`Localization`/`Themes`/`Levels` 갤러리로 재구성 +- README 설치/API/선택 예제와 실제 에뮬레이터 라이트·다크 이미지를 1.2.0 기준으로 갱신 +- README/AGENTS/CLAUDE/CODE_QUALITY의 버전과 내부 구조 설명 동기화 + +### Fixed +- 롱클릭 전용 셀에 빈 클릭 액션이 함께 노출되던 접근성 semantics 수정 +- 일요일 시작 그래프가 기본 월요일 순서 라벨을 표시하던 불일치 수정 ## [1.1.1] - 2026-06-11 @@ -115,7 +134,8 @@ - 월 라벨 위치 결정 (`createMonthLabels`) - 연도 라벨 포맷팅 (`formatYearLabel`) -[Unreleased]: https://github.com/ois0886/compose-git-grass/compare/v1.1.1...HEAD +[Unreleased]: https://github.com/ois0886/compose-git-grass/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/ois0886/compose-git-grass/compare/v1.1.1...v1.2.0 [1.1.1]: https://github.com/ois0886/compose-git-grass/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/ois0886/compose-git-grass/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/ois0886/compose-git-grass/compare/v0.1.1...v1.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 74a2d83..3a3645c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ ## Quick Facts - 배포 좌표: `io.github.ois0886:compose-git-grass:` -- 현재 릴리즈: `1.1.1` +- 현재 릴리즈: `1.2.0` - 라이브러리 모듈: `:library` (`com.inseong.gitgrass`) - 샘플 앱 모듈: `:app` diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index 719eaf9..86a756d 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -20,7 +20,7 @@ compose-git-grass 프로젝트의 코드 퀄리티 가이드라인. - **Public API는 최소화**한다. 외부에 노출할 필요가 없는 모든 것은 `internal`로 선언한다. - Public: `GitGrass`, `GitGrassColors`, `GitGrassDefaults`, `GitGrassStreakInfo` -- Internal: UI 컴포넌트 (`YearLabel`, `MonthRow`, `GrassCell` 등), 유틸리티 함수 (`buildGrid`, `calculateStreak` 등) +- Internal: UI 컴포넌트 (`YearLabel`, `MonthLabelSlot`, `GrassCell` 등), 유틸리티 함수 (`buildGrid`, `calculateStreak` 등) ### 데이터 클래스 @@ -77,7 +77,8 @@ fun GitGrass(...) { 3. 스타일 (colors, cellSize, fontSize) 4. UI 토글 (showYearLabel, showLegend) 5. 텍스트 라벨 (streakMaxLabel, lessLabel) -6. 람다/콜백 (levelOf, onCellClick, onCellLongClick) +6. 매핑/제어 상태 (levelOf, selection) +7. 람다/콜백 (onCellClick, onCellLongClick) ``` ### remember 사용 규칙 @@ -156,10 +157,9 @@ UI 렌더링 (Composable + remember) ``` GitGrass (루트 조율자) ├── YearLabel -├── MonthRow (LazyListState 공유) ├── WeekLabelColumn -├── GrassGridContent (LazyListState 공유) -│ └── GrassWeekColumn (Canvas) → GrassCell (hit target) +├── GrassGridContent (단일 LazyRow) +│ └── MonthLabelSlot + GrassWeekColumn (Canvas) → GrassCell (hit target) ├── StreakSummary └── ColorLegend ``` @@ -287,12 +287,14 @@ val renderGrid = remember(grid, safeContributions, colors, levelOf) { ### Lazy 렌더링 우선 -주 단위처럼 반복 개수가 날짜 범위에 비례하는 UI는 eager `Row`보다 `LazyRow`를 우선한다. 월 라벨과 그리드는 같은 `LazyListState`를 공유해 정렬과 스크롤 동기화를 유지한다. +주 단위처럼 반복 개수가 날짜 범위에 비례하는 UI는 eager `Row`보다 `LazyRow`를 우선한다. 월 라벨과 주별 Canvas는 같은 lazy item 안에 배치해 별도 상태 동기화 없이 정렬을 보장한다. ### Canvas와 Semantics 분리 반복 셀의 시각 렌더링은 Canvas로 합치되, 접근성/클릭/롱클릭은 별도의 hit target 컴포저블로 유지한다. 사용자가 렌더러를 직접 선택하는 public 옵션은 실제 요구가 생기기 전까지 추가하지 않는다. +선택 아웃라인처럼 자주 바뀌는 제어 상태는 `buildRenderGrid` 입력에 넣지 않는다. 보이는 Canvas에서 날짜를 비교해 그리며 hit target에는 `selected` semantics를 별도로 적용한다. + ### Consumer Rules 범위 제한 AAR consumer ProGuard 규칙은 앱 전체에 영향을 주므로 `com.inseong.gitgrass.**`처럼 라이브러리 패키지 범위로 한정한다. 전역 `class *` keep 규칙은 피한다. @@ -566,7 +568,6 @@ fun GitGrass(...) { val grid = remember(...) { buildGrid(days, weekStartDay) } Column { YearLabel(...) - MonthRow(...) GrassGridContent(...) } } @@ -592,8 +593,8 @@ fun GitGrass(...) { // O: 컴포지션 — 작은 컴포저블 조합 GitGrass (루트 조율자) ├── YearLabel // 독립 컴포넌트 -├── MonthRow // LazyListState를 파라미터로 주입 -├── GrassGridContent // 렌더링 데이터와 LazyListState를 파라미터로 주입 +├── WeekLabelColumn // 고정 요일 라벨 +├── GrassGridContent // 월 라벨과 주별 Canvas를 하나의 LazyRow로 구성 └── ColorLegend // 독립 컴포넌트 // X: 상속 기반 접근 diff --git a/README.md b/README.md index b62df57..28651ec 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Jetpack Compose에서 사용할 수 있는 가볍고 독립적인 GitHub 잔디( [![API](https://img.shields.io/badge/API-26%2B-brightgreen.svg)](https://developer.android.com/about/versions/oreo)

- compose-git-grass sample + compose-git-grass light showcase with a selected contribution day + compose-git-grass dark showcase with a selected contribution day

## Why? @@ -25,16 +26,16 @@ GitHub의 잔디 그래프는 활동량을 한눈에 보여주는 훌륭한 시 ## Setup -현재 최신 릴리즈는 `1.1.1`입니다. +현재 최신 릴리즈는 `1.2.0`입니다. ```kotlin // build.gradle.kts dependencies { - implementation("io.github.ois0886:compose-git-grass:1.1.1") + implementation("io.github.ois0886:compose-git-grass:1.2.0") } ``` -> **1.0.0에서 마이그레이션**: 새 파라미터(`cellContentDescription`, `cellClickLabel`)가 추가되었으며 모두 기본값이 있어 기존 코드가 그대로 동작합니다. `GitGrassColors.border`는 deprecated 되었습니다 (2.0.0에서 제거 예정). +> **1.1.x에서 업그레이드**: `selection` 파라미터가 기본값과 함께 추가되어 기존 호출은 그대로 동작합니다. 일요일 시작 그래프의 기본 요일 라벨은 이제 자동으로 일요일부터 정렬됩니다. `GitGrassColors.border`는 계속 deprecated 상태이며 2.0.0에서 제거될 예정입니다. ## Quick Start @@ -48,6 +49,21 @@ GitGrass( ) ``` +```kotlin +// 제어 가능한 날짜 선택 +var selectedDate by remember { mutableStateOf(null) } + +GitGrass( + contributions = data, + selection = selectedDate?.let { date -> + GitGrassSelection(date = date) + }, + onCellClick = { date, _ -> + selectedDate = date + }, +) +``` + ```kotlin // 다크모드 + 스트릭 + 셀 클릭 + 범례 GitGrass( @@ -66,6 +82,7 @@ GitGrass( 대부분의 1년 단위 그래프는 기본 설정만으로 충분히 가볍게 동작합니다. 여러 개의 그래프를 한 화면에 렌더링하거나 5년 이상의 긴 범위를 표시한다면 아래 원칙을 권장합니다: - 별도 `renderMode`를 고를 필요는 없습니다. 내부에서 주 단위 lazy 렌더링과 Canvas 기반 셀 그리기를 자동으로 사용합니다. +- 월 라벨과 잔디 셀은 하나의 `LazyRow`에서 함께 구성되므로 별도의 스크롤 동기화 비용이 없습니다. - `contributions`, `startDate`, `endDate`, 로케일 라벨, 커스텀 색상 팔레트는 `remember`나 ViewModel 상태로 안정적으로 전달하세요. - 필요 없는 보조 UI는 `showMonthLabels`, `showStreak`, `showLegend`를 `false`로 꺼두면 해당 계산과 렌더링 비용을 줄일 수 있습니다. - 매 리컴포지션마다 `LocalDate.now()`, `localizedMonthLabels()`, `GitGrassColors(...)`를 새로 만들기보다 한 번 계산한 값을 재사용하세요. @@ -87,6 +104,29 @@ GitGrass( ## Customization +### Controlled Selection (1.2.0 신규) + +`GitGrass`는 선택 상태를 내부에서 변경하지 않습니다. 앱 상태를 [GitGrassSelection](#gitgrassselection)으로 전달하고 클릭 콜백에서 갱신하세요. + +```kotlin +var selectedDate by remember { mutableStateOf(LocalDate.now()) } + +GitGrass( + contributions = data, + selection = GitGrassSelection( + date = selectedDate, + outlineColor = Color.Blue, + outlineWidth = 2.dp, + ), + onCellClick = { date, count -> + selectedDate = date + println("$date: $count") + }, +) +``` + +선택 날짜가 표시 범위 밖이면 강조하지 않습니다. 선택 변경만으로 스크롤 위치가 바뀌지는 않으며, `outlineColor = Color.Unspecified`일 때는 현재 색상 스킴의 `text` 색상을 사용합니다. + ### Colors 원하는 색상과 레벨 수를 자유롭게 지정할 수 있습니다: @@ -153,10 +193,11 @@ GitGrass( contributions = data, weekStartDay = DayOfWeek.SUNDAY, startDate = GitGrassDefaults.startDate(DayOfWeek.SUNDAY), - weekLabels = GitGrassDefaults.localizedWeekLabels(DayOfWeek.SUNDAY), ) ``` +영어 기본 라벨은 `weekStartDay`를 자동으로 따릅니다. 로컬라이즈된 라벨이 필요할 때만 `GitGrassDefaults.localizedWeekLabels(DayOfWeek.SUNDAY)`를 전달하면 됩니다. + ### Long Press (1.0.0 신규) ```kotlin @@ -204,7 +245,7 @@ GitGrass( | `weekStartDay` | `DayOfWeek` | MONDAY | 주 시작 요일 | | `colors` | `GitGrassColors` | GitHub 라이트 | 색상 스킴 | | `monthLabels` | `List` | 영어 | 월 이름 (인덱스 0 미사용) | -| `weekLabels` | `List` | 영어 | 요일 이름 | +| `weekLabels` | `List` | 주 시작일 기준 영어 | 요일 이름 | | `cellSize` | `Dp` | 12.dp | 셀 크기 | | `cellSpacing` | `Dp` | 3.dp | 셀 간격 | | `cellCornerRadius` | `Dp` | 2.dp | 셀 모서리 반경 | @@ -221,6 +262,7 @@ GitGrass( | `cellContentDescription` | `(LocalDate, Int) -> String` | `"$date: $count"` | 셀 접근성 텍스트 (1.1.0) | | `cellClickLabel` | `(LocalDate) -> String` | `"$date details"` | 셀 클릭 접근성 라벨 (1.1.0) | | `levelOf` | `(Int) -> Int` | 0/1-3/4-6/7-9/10+ | 기여 횟수 → 레벨 매핑 | +| `selection` | `GitGrassSelection?` | null | 제어 가능한 선택 셀과 인셋 아웃라인 (1.2.0) | | `onCellClick` | `((LocalDate, Int) -> Unit)?` | null | 셀 탭 콜백 | | `onCellLongClick` | `((LocalDate, Int) -> Unit)?` | null | 셀 롱프레스 콜백 | @@ -233,6 +275,14 @@ GitGrass( | `text` | 라벨 텍스트 색상 | | `border` | ~~셀 테두리 색상~~ (deprecated, 2.0.0에서 제거 예정) | +### `GitGrassSelection` + +| Field | Default | Description | +|---|---|---| +| `date` | *필수* | 선택해 강조할 날짜 | +| `outlineColor` | `Color.Unspecified` | 인셋 아웃라인 색상 (`colors.text`로 대체) | +| `outlineWidth` | 2.dp | 인셋 아웃라인 두께 (셀 크기에 맞춰 안전하게 제한) | + ### Forgiving Input 잘못된 입력도 크래시 없이 안전하게 처리합니다: @@ -246,6 +296,8 @@ GitGrass( - 모든 셀에 `contentDescription` 제공 (스크린 리더 지원) - 클릭 가능한 셀에 `Role.Button` 및 `onClickLabel` 설정 +- 선택된 셀에 `selected` semantics 제공 +- 롱클릭 전용 셀에는 빈 클릭 액션 없이 롱클릭 semantics만 제공 - 그래프 루트에 "Contribution graph" semantics 적용 - 범례 셀에 "Level 0", "Level 1" 등 접근성 라벨 제공 - `cellContentDescription`, `cellClickLabel` 파라미터로 접근성 텍스트 커스터마이징 가능 (1.1.0) @@ -265,10 +317,11 @@ GitGrass( - **유연한 색상 레벨** - 고정된 `level1`~`level4` 대신 `levels: List`를 사용합니다. 3단계든, 5단계든, 10단계든 자유롭게 지정할 수 있습니다. - **픽셀 단위 월 라벨 정렬** - 월 라벨이 그리드와 동일한 `Arrangement.spacedBy` + 고정 너비 슬롯 구조를 사용하여, 어떤 셀 크기에서도 정확하게 정렬됩니다. - **자동 최적화 렌더링** - 긴 날짜 범위에서는 주 단위 `LazyRow`가 화면에 필요한 열만 구성하고, 셀 색상은 Canvas로 그려 반복 노드 비용을 줄입니다. -- **공유 LazyListState** - 월 라벨 행과 그리드가 하나의 `LazyListState`를 공유하여 가로 스크롤 시 항상 동기화됩니다. -- **순수 함수 & 테스트** - 그리드/스트릭 계산 로직이 부수효과 없는 순수 함수로 구현되어 있으며, 60+ 유닛 테스트와 Compose UI 테스트가 그리드 생성, 스트릭 계산, 색상 매핑, 엣지 케이스, UI 인터랙션을 검증합니다. +- **단일 LazyRow** - 월 라벨과 주별 Canvas를 같은 lazy item에서 구성해 스크롤 정합성을 구조적으로 보장합니다. +- **선택 렌더링 분리** - 선택 아웃라인은 render grid를 다시 만들지 않고 보이는 Canvas와 semantics만 갱신합니다. +- **순수 함수 & 테스트** - 그리드/스트릭/월 경계/초기 스크롤 계산이 순수 함수로 구현되어 있으며, 유닛 테스트와 Compose UI 테스트가 엣지 케이스와 인터랙션을 검증합니다. - **접근성** - 모든 셀에 contentDescription, semantics 적용으로 스크린 리더를 지원합니다. -- **오늘 날짜로 자동 스크롤** - `LaunchedEffect`로 첫 컴포지션 시 가장 최근 날짜(오른쪽 끝)로 자동 스크롤됩니다. +- **완전한 주 단위 자동 스크롤** - 첫 컴포지션에서 최신 날짜를 유지하면서 시작 월 라벨이 잘리지 않는 완전한 주 열로 스냅합니다. ## Requirements diff --git a/app/src/main/java/com/inseong/compose_git_grass/MainActivity.kt b/app/src/main/java/com/inseong/compose_git_grass/MainActivity.kt index c5e7334..4545ee3 100644 --- a/app/src/main/java/com/inseong/compose_git_grass/MainActivity.kt +++ b/app/src/main/java/com/inseong/compose_git_grass/MainActivity.kt @@ -5,32 +5,32 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.inseong.compose_git_grass.data.generateSampleData -import com.inseong.compose_git_grass.data.generateSparseData -import com.inseong.compose_git_grass.demos.BasicDemo -import com.inseong.compose_git_grass.demos.CellClickDemo -import com.inseong.compose_git_grass.demos.CustomCellSizingDemo -import com.inseong.compose_git_grass.demos.CustomColorsDemo -import com.inseong.compose_git_grass.demos.CustomDateRangeDemo -import com.inseong.compose_git_grass.demos.CustomLevelMappingDemo -import com.inseong.compose_git_grass.demos.DarkThemeDemo -import com.inseong.compose_git_grass.demos.KoreanLocalizationDemo -import com.inseong.compose_git_grass.demos.ToggleVisibilityDemo -import com.inseong.compose_git_grass.demos.WeekStartDayDemo +import com.inseong.compose_git_grass.demos.FeaturedContributionDemo +import com.inseong.compose_git_grass.demos.LayoutGallery +import com.inseong.compose_git_grass.demos.LevelsGallery +import com.inseong.compose_git_grass.demos.LocalizationGallery +import com.inseong.compose_git_grass.demos.ThemeGallery import com.inseong.compose_git_grass.ui.theme.ComposegitgrassTheme class MainActivity : ComponentActivity() { @@ -43,8 +43,27 @@ class MainActivity : ComponentActivity() { val snackbarHostState = remember { SnackbarHostState() } Scaffold( modifier = Modifier.fillMaxSize(), + containerColor = MaterialTheme.colorScheme.background, topBar = { - TopAppBar(title = { Text("compose-git-grass") }) + TopAppBar( + title = { + Column { + Text( + text = "compose-git-grass", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Jetpack Compose contribution graph", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background, + ), + ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> @@ -64,22 +83,61 @@ fun GitGrassShowcase( snackbarHostState: SnackbarHostState, ) { val sampleData = remember { generateSampleData() } - val sparseData = remember { generateSparseData() } LazyColumn( modifier = modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), + contentPadding = PaddingValues(start = 18.dp, top = 18.dp, end = 18.dp, bottom = 36.dp), verticalArrangement = Arrangement.spacedBy(20.dp), ) { - item { BasicDemo(sampleData) } - item { DarkThemeDemo(sampleData) } - item { CustomDateRangeDemo(sparseData) } - item { WeekStartDayDemo(sampleData) } - item { KoreanLocalizationDemo(sampleData) } - item { CustomCellSizingDemo(sampleData) } - item { ToggleVisibilityDemo(sampleData) } - item { CustomLevelMappingDemo(sampleData) } - item { CustomColorsDemo(sampleData) } - item { CellClickDemo(sampleData, snackbarHostState) } + item { + ShowcaseIntro() + } + item { + FeaturedContributionDemo(sampleData, snackbarHostState) + } + item { + GalleryIntro() + } + item { + LayoutGallery(sampleData) + } + item { + LocalizationGallery(sampleData) + } + item { + ThemeGallery(sampleData) + } + item { + LevelsGallery(sampleData) + } + } +} + +@Composable +private fun ShowcaseIntro() { + Column { + Text( + text = "Daily activity, clearly composed.", + style = MaterialTheme.typography.headlineMedium, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "A lightweight, Foundation-only contribution graph with controlled selection, localization, and accessible interactions.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun GalleryIntro() { + Column { + Text(text = "API gallery", style = MaterialTheme.typography.titleLarge) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "The core customization paths, grouped by intent.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } diff --git a/app/src/main/java/com/inseong/compose_git_grass/components/SectionCard.kt b/app/src/main/java/com/inseong/compose_git_grass/components/SectionCard.kt index 6b2e987..9cf9207 100644 --- a/app/src/main/java/com/inseong/compose_git_grass/components/SectionCard.kt +++ b/app/src/main/java/com/inseong/compose_git_grass/components/SectionCard.kt @@ -1,15 +1,21 @@ package com.inseong.compose_git_grass.components +import androidx.compose.foundation.BorderStroke +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.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -17,23 +23,64 @@ import androidx.compose.ui.unit.dp internal fun SectionCard( title: String, description: String, + badge: String? = null, content: @Composable () -> Unit, ) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + shape = RoundedCornerShape(16.dp), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), ) { - Column(modifier = Modifier.padding(16.dp)) { - Text(text = title, style = MaterialTheme.typography.titleMedium) + Column(modifier = Modifier.padding(20.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(text = title, style = MaterialTheme.typography.titleLarge) + if (badge != null) { + Surface( + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), + contentColor = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(999.dp), + ) { + Text( + text = badge, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } + Spacer(modifier = Modifier.height(4.dp)) Text( text = description, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(18.dp)) content() } } } + +@Composable +internal fun DemoBlock( + title: String, + description: String, + content: @Composable () -> Unit, +) { + Column { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(10.dp)) + content() + } +} diff --git a/app/src/main/java/com/inseong/compose_git_grass/data/ShowcaseMetrics.kt b/app/src/main/java/com/inseong/compose_git_grass/data/ShowcaseMetrics.kt new file mode 100644 index 0000000..915571f --- /dev/null +++ b/app/src/main/java/com/inseong/compose_git_grass/data/ShowcaseMetrics.kt @@ -0,0 +1,34 @@ +package com.inseong.compose_git_grass.data + +import androidx.compose.runtime.Immutable +import java.time.LocalDate + +@Immutable +internal data class ShowcaseMetrics( + val totalContributions: Long, + val activeDays: Int, +) + +internal fun calculateShowcaseMetrics( + contributions: Map, + startDate: LocalDate, + endDate: LocalDate, +): ShowcaseMetrics { + val firstDate = minOf(startDate, endDate) + val lastDate = maxOf(startDate, endDate) + var totalContributions = 0L + var activeDays = 0 + + for ((date, rawCount) in contributions) { + if (date.isBefore(firstDate) || date.isAfter(lastDate)) continue + + val count = rawCount.coerceAtLeast(0) + totalContributions += count + if (count > 0) activeDays++ + } + + return ShowcaseMetrics( + totalContributions = totalContributions, + activeDays = activeDays, + ) +} diff --git a/app/src/main/java/com/inseong/compose_git_grass/demos/BasicDemos.kt b/app/src/main/java/com/inseong/compose_git_grass/demos/BasicDemos.kt deleted file mode 100644 index 05366aa..0000000 --- a/app/src/main/java/com/inseong/compose_git_grass/demos/BasicDemos.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.inseong.compose_git_grass.demos - -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.inseong.compose_git_grass.components.SectionCard -import com.inseong.gitgrass.GitGrass -import com.inseong.gitgrass.GitGrassDefaults -import java.time.LocalDate - -@Composable -internal fun BasicDemo(data: Map) { - SectionCard( - title = "Basic", - description = "Default settings - just pass contributions data", - ) { - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - ) - } -} - -@Composable -internal fun DarkThemeDemo(data: Map) { - SectionCard( - title = "Dark Theme", - description = "GitGrassDefaults.darkColors() on dark background", - ) { - Surface( - color = Color(0xFF0D1117), - shape = RoundedCornerShape(8.dp), - ) { - GitGrass( - contributions = data, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - colors = GitGrassDefaults.darkColors(), - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Text( - text = "Auto theme (follows system)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - val autoColors = if (isSystemInDarkTheme()) { - GitGrassDefaults.darkColors() - } else { - GitGrassDefaults.colors() - } - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - colors = autoColors, - ) - } -} diff --git a/app/src/main/java/com/inseong/compose_git_grass/demos/CustomizationDemos.kt b/app/src/main/java/com/inseong/compose_git_grass/demos/CustomizationDemos.kt index aeda3bc..3b35c5d 100644 --- a/app/src/main/java/com/inseong/compose_git_grass/demos/CustomizationDemos.kt +++ b/app/src/main/java/com/inseong/compose_git_grass/demos/CustomizationDemos.kt @@ -1,9 +1,13 @@ package com.inseong.compose_git_grass.demos +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.inseong.compose_git_grass.components.DemoBlock import com.inseong.compose_git_grass.components.SectionCard import com.inseong.gitgrass.GitGrass import com.inseong.gitgrass.GitGrassDefaults @@ -12,60 +16,76 @@ import java.time.LocalDate import java.util.Locale @Composable -internal fun CustomDateRangeDemo(data: Map) { - val dateRange = remember { - val end = LocalDate.now() - end.minusMonths(3) to end - } +internal fun LayoutGallery(data: Map) { + val endDate = remember { LocalDate.now() } + val recentStart = remember(endDate) { endDate.minusMonths(3) } SectionCard( - title = "Custom Date Range", - description = "Display only the last 3 months", + title = "Layout", + description = "Tune the visible range, cell density, week origin, and supporting labels.", + badge = "ADAPTIVE", ) { - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - ) - } -} + DemoBlock( + title = "Focused quarter", + description = "A compact 90-day view with larger rounded cells.", + ) { + GitGrass( + contributions = data, + modifier = Modifier.fillMaxWidth(), + startDate = recentStart, + endDate = endDate, + cellSize = 16.dp, + cellSpacing = 4.dp, + cellCornerRadius = 5.dp, + showYearLabel = false, + ) + } -@Composable -internal fun WeekStartDayDemo(data: Map) { - val sundayWeekLabels = remember { - GitGrassDefaults.localizedWeekLabels( - weekStartDay = DayOfWeek.SUNDAY, - ) - } + Spacer(modifier = Modifier.height(24.dp)) - SectionCard( - title = "Week Start Day", - description = "weekStartDay = SUNDAY", - ) { - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - weekStartDay = DayOfWeek.SUNDAY, - weekLabels = sundayWeekLabels, - ) + DemoBlock( + title = "Sunday-first, minimal chrome", + description = "Default labels now automatically follow the configured week start.", + ) { + GitGrass( + contributions = data, + modifier = Modifier.fillMaxWidth(), + startDate = recentStart, + endDate = endDate, + weekStartDay = DayOfWeek.SUNDAY, + showYearLabel = false, + showStreak = false, + showLegend = false, + ) + } } } @Composable -internal fun KoreanLocalizationDemo(data: Map) { +internal fun LocalizationGallery(data: Map) { + val endDate = remember { LocalDate.now() } + val startDate = remember(endDate) { endDate.minusMonths(6) } val monthLabels = remember { GitGrassDefaults.localizedMonthLabels(Locale.KOREAN) } - val weekLabels = remember { GitGrassDefaults.localizedWeekLabels(locale = Locale.KOREAN) } + val weekLabels = remember { + GitGrassDefaults.localizedWeekLabels( + weekStartDay = DayOfWeek.MONDAY, + locale = Locale.KOREAN, + ) + } SectionCard( - title = "Korean Labels", - description = "Localized month/week labels + custom streak/legend text", + title = "Localization", + description = "Localize calendar labels, summaries, legends, and accessibility text together.", + badge = "한국어", ) { GitGrass( contributions = data, modifier = Modifier.fillMaxWidth(), + startDate = startDate, + endDate = endDate, monthLabels = monthLabels, weekLabels = weekLabels, + showYearLabel = false, showStreak = true, streakMaxLabel = "최대 연속", streakCurrentLabel = "현재 연속", diff --git a/app/src/main/java/com/inseong/compose_git_grass/demos/FeaturedDemo.kt b/app/src/main/java/com/inseong/compose_git_grass/demos/FeaturedDemo.kt new file mode 100644 index 0000000..4c9e4ae --- /dev/null +++ b/app/src/main/java/com/inseong/compose_git_grass/demos/FeaturedDemo.kt @@ -0,0 +1,171 @@ +package com.inseong.compose_git_grass.demos + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.inseong.compose_git_grass.components.SectionCard +import com.inseong.compose_git_grass.data.calculateShowcaseMetrics +import com.inseong.gitgrass.GitGrass +import com.inseong.gitgrass.GitGrassDefaults +import com.inseong.gitgrass.GitGrassSelection +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlinx.coroutines.launch + +@Composable +internal fun FeaturedContributionDemo( + data: Map, + snackbarHostState: SnackbarHostState, +) { + val endDate = remember { GitGrassDefaults.endDate() } + val startDate = remember(endDate) { endDate.minusYears(1) } + val metrics = remember(data, startDate, endDate) { + calculateShowcaseMetrics(data, startDate, endDate) + } + val dateFormatter = remember { + DateTimeFormatter.ofPattern("EEE, MMM d, yyyy", Locale.ENGLISH) + } + var selectedDate by remember(endDate) { mutableStateOf(endDate) } + val selectedCount = data[selectedDate]?.coerceAtLeast(0) ?: 0 + val isDarkTheme = isSystemInDarkTheme() + val graphColors = if (isDarkTheme) { + GitGrassDefaults.darkColors() + } else { + GitGrassDefaults.colors() + } + val selectedLevel = GitGrassDefaults.levelThresholds(selectedCount) + val selectedColor = if (selectedLevel <= 0 || graphColors.levels.isEmpty()) { + graphColors.empty + } else { + graphColors.levels[(selectedLevel - 1).coerceIn(0, graphColors.levels.lastIndex)] + } + val scope = rememberCoroutineScope() + + SectionCard( + title = "Contribution activity", + description = "Tap any day to inspect it. Long-press a cell to try the secondary action.", + badge = "INTERACTIVE", + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + MetricPill( + value = metrics.totalContributions.toString(), + label = "contributions", + modifier = Modifier.weight(1f), + ) + MetricPill( + value = metrics.activeDays.toString(), + label = "active days", + modifier = Modifier.weight(1f), + ) + } + + Spacer(modifier = Modifier.height(18.dp)) + + GitGrass( + contributions = data, + modifier = Modifier.fillMaxWidth(), + startDate = startDate, + endDate = endDate, + colors = graphColors, + showYearLabel = false, + showStreak = false, + selection = GitGrassSelection( + date = selectedDate, + outlineColor = MaterialTheme.colorScheme.onSurface, + ), + onCellClick = { date, _ -> selectedDate = date }, + onCellLongClick = { date, count -> + scope.launch { + snackbarHostState.currentSnackbarData?.dismiss() + snackbarHostState.showSnackbar( + message = "$date has $count contributions", + ) + } + }, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(12.dp) + .background(selectedColor, RoundedCornerShape(3.dp)), + ) + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = selectedDate.format(dateFormatter), + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = "$selectedCount contributions · level $selectedLevel", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun MetricPill( + value: String, + label: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(12.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/inseong/compose_git_grass/demos/InteractionDemos.kt b/app/src/main/java/com/inseong/compose_git_grass/demos/InteractionDemos.kt deleted file mode 100644 index 66ed91f..0000000 --- a/app/src/main/java/com/inseong/compose_git_grass/demos/InteractionDemos.kt +++ /dev/null @@ -1,105 +0,0 @@ -package com.inseong.compose_git_grass.demos - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.inseong.compose_git_grass.components.SectionCard -import com.inseong.gitgrass.GitGrass -import kotlinx.coroutines.launch -import java.time.LocalDate - -@Composable -internal fun ToggleVisibilityDemo(data: Map) { - val dateRange = remember { - val end = LocalDate.now() - end.minusMonths(3) to end - } - - SectionCard( - title = "Toggle Visibility", - description = "All labels/streak/legend hidden vs shown", - ) { - Text( - text = "Everything hidden", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - showYearLabel = false, - showWeekLabels = false, - showMonthLabels = false, - showStreak = false, - showLegend = false, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Everything shown", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - showYearLabel = true, - showWeekLabels = true, - showMonthLabels = true, - showStreak = true, - showLegend = true, - ) - } -} - -@Composable -internal fun CellClickDemo( - data: Map, - snackbarHostState: SnackbarHostState, -) { - val scope = rememberCoroutineScope() - val dateRange = remember { - val end = LocalDate.now() - end.minusMonths(3) to end - } - - SectionCard( - title = "Cell Click & Long-Click", - description = "Tap or long-press a cell to see a Snackbar", - ) { - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - showStreak = true, - onCellClick = { date, count -> - scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar("Clicked: $date ($count contributions)") - } - }, - onCellLongClick = { date, count -> - scope.launch { - snackbarHostState.currentSnackbarData?.dismiss() - snackbarHostState.showSnackbar("Long-pressed: $date ($count contributions)") - } - }, - ) - } -} diff --git a/app/src/main/java/com/inseong/compose_git_grass/demos/StylingDemos.kt b/app/src/main/java/com/inseong/compose_git_grass/demos/StylingDemos.kt index 1aedbfe..bdced07 100644 --- a/app/src/main/java/com/inseong/compose_git_grass/demos/StylingDemos.kt +++ b/app/src/main/java/com/inseong/compose_git_grass/demos/StylingDemos.kt @@ -3,153 +3,128 @@ package com.inseong.compose_git_grass.demos import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.inseong.compose_git_grass.components.DemoBlock import com.inseong.compose_git_grass.components.SectionCard import com.inseong.gitgrass.GitGrass import com.inseong.gitgrass.GitGrassColors +import com.inseong.gitgrass.GitGrassDefaults import java.time.LocalDate @Composable -internal fun CustomCellSizingDemo(data: Map) { - val dateRange = remember { - val end = LocalDate.now() - end.minusMonths(3) to end +internal fun ThemeGallery(data: Map) { + val endDate = remember { LocalDate.now() } + val startDate = remember(endDate) { endDate.minusMonths(5) } + val blueColors = remember { + GitGrassColors( + empty = Color(0xFFEFF6FF), + levels = listOf( + Color(0xFFBFDBFE), + Color(0xFF60A5FA), + Color(0xFF2563EB), + Color(0xFF1E3A8A), + ), + text = Color(0xFF1E3A8A), + ) } SectionCard( - title = "Custom Cell Sizing", - description = "Large rounded cells vs tiny dense cells", + title = "Themes", + description = "Use the GitHub presets or provide any number of custom color levels.", + badge = "COLOR", ) { - Text( - text = "Large (20dp, radius 10dp)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - cellSize = 20.dp, - cellSpacing = 4.dp, - cellCornerRadius = 10.dp, - labelFontSize = 14.sp, - ) - - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = "Tiny (8dp, radius 0dp)", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - cellSize = 8.dp, - cellSpacing = 1.dp, - cellCornerRadius = 0.dp, - labelFontSize = 8.sp, - ) - } -} + DemoBlock( + title = "GitHub dark", + description = "The built-in dark palette on its native canvas.", + ) { + Surface( + color = Color(0xFF0D1117), + shape = RoundedCornerShape(12.dp), + ) { + GitGrass( + contributions = data, + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + startDate = startDate, + endDate = endDate, + colors = GitGrassDefaults.darkColors(), + showYearLabel = false, + ) + } + } -@Composable -internal fun CustomLevelMappingDemo(data: Map) { - val dateRange = remember { - val end = LocalDate.now() - end.minusMonths(3) to end - } + Spacer(modifier = Modifier.height(24.dp)) - SectionCard( - title = "Custom Level Mapping", - description = "Binary: any contribution = max level", - ) { - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - levelOf = { count -> if (count > 0) 4 else 0 }, - ) + DemoBlock( + title = "Blue scale", + description = "A custom four-level palette without Material dependencies.", + ) { + GitGrass( + contributions = data, + modifier = Modifier.fillMaxWidth(), + startDate = startDate, + endDate = endDate, + colors = blueColors, + showYearLabel = false, + ) + } } } -@Suppress("DEPRECATION") @Composable -internal fun CustomColorsDemo(data: Map) { - val dateRange = remember { - val end = LocalDate.now() - end.minusMonths(6) to end - } - val blueOceanColors = remember { - GitGrassColors( - empty = Color(0xFFE8EAF6), - levels = listOf( - Color(0xFFBBDEFB), - Color(0xFF64B5F6), - Color(0xFF1E88E5), - Color(0xFF0D47A1), - ), - text = Color(0xFF1A237E), - border = Color(0xFFC5CAE9), - ) - } - val warmSunsetColors = remember { - GitGrassColors( - empty = Color(0xFFFFF3E0), - levels = listOf( - Color(0xFFFFCC80), - Color(0xFFFF9800), - Color(0xFFF57C00), - Color(0xFFE65100), - ), - text = Color(0xFFBF360C), - border = Color(0xFFFFE0B2), - ) - } +internal fun LevelsGallery(data: Map) { + val endDate = remember { LocalDate.now() } + val startDate = remember(endDate) { endDate.minusMonths(4) } SectionCard( - title = "Custom Colors", - description = "Blue ocean & warm sunset color palettes", + title = "Levels", + description = "Map domain values to visual intensity with a small pure function.", + badge = "MAPPING", ) { - Text( - text = "Blue Ocean", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - colors = blueOceanColors, - ) + DemoBlock( + title = "Binary activity", + description = "Every active day uses the strongest level.", + ) { + GitGrass( + contributions = data, + modifier = Modifier.fillMaxWidth(), + startDate = startDate, + endDate = endDate, + showYearLabel = false, + levelOf = { count -> if (count > 0) 4 else 0 }, + ) + } - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) - Text( - text = "Warm Sunset", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - GitGrass( - contributions = data, - modifier = Modifier.fillMaxWidth(), - startDate = dateRange.first, - endDate = dateRange.second, - colors = warmSunsetColors, - ) + DemoBlock( + title = "Wide thresholds", + description = "A slower ramp for higher-volume activity data.", + ) { + GitGrass( + contributions = data, + modifier = Modifier.fillMaxWidth(), + startDate = startDate, + endDate = endDate, + showYearLabel = false, + levelOf = { count -> + when { + count <= 0 -> 0 + count < 5 -> 1 + count < 10 -> 2 + count < 15 -> 3 + else -> 4 + } + }, + ) + } } } diff --git a/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Color.kt b/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Color.kt index d260405..c82125a 100644 --- a/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Color.kt +++ b/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Color.kt @@ -2,10 +2,18 @@ package com.inseong.compose_git_grass.ui.theme import androidx.compose.ui.graphics.Color -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) +internal val GitHubLightBackground = Color(0xFFF6F8FA) +internal val GitHubLightSurface = Color(0xFFFFFFFF) +internal val GitHubLightSurfaceVariant = Color(0xFFF6F8FA) +internal val GitHubLightText = Color(0xFF1F2328) +internal val GitHubLightMutedText = Color(0xFF59636E) +internal val GitHubLightBorder = Color(0xFFD0D7DE) +internal val GitHubLightPrimary = Color(0xFF1F883D) -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file +internal val GitHubDarkBackground = Color(0xFF0D1117) +internal val GitHubDarkSurface = Color(0xFF161B22) +internal val GitHubDarkSurfaceVariant = Color(0xFF21262D) +internal val GitHubDarkText = Color(0xFFE6EDF3) +internal val GitHubDarkMutedText = Color(0xFF8B949E) +internal val GitHubDarkBorder = Color(0xFF30363D) +internal val GitHubDarkPrimary = Color(0xFF3FB950) diff --git a/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Theme.kt b/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Theme.kt index 0aac650..cedcb12 100644 --- a/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Theme.kt +++ b/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Theme.kt @@ -1,58 +1,48 @@ package com.inseong.compose_git_grass.ui.theme -import android.app.Activity -import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.dynamicDarkColorScheme -import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.Color private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 + primary = GitHubDarkPrimary, + onPrimary = GitHubDarkBackground, + background = GitHubDarkBackground, + onBackground = GitHubDarkText, + surface = GitHubDarkSurface, + onSurface = GitHubDarkText, + surfaceVariant = GitHubDarkSurfaceVariant, + onSurfaceVariant = GitHubDarkMutedText, + outline = GitHubDarkBorder, + outlineVariant = GitHubDarkBorder, + error = Color(0xFFF85149), ) private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), + primary = GitHubLightPrimary, onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ + background = GitHubLightBackground, + onBackground = GitHubLightText, + surface = GitHubLightSurface, + onSurface = GitHubLightText, + surfaceVariant = GitHubLightSurfaceVariant, + onSurfaceVariant = GitHubLightMutedText, + outline = GitHubLightBorder, + outlineVariant = GitHubLightBorder, + error = Color(0xFFCF222E), ) @Composable fun ComposegitgrassTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ - dynamicColor: Boolean = true, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { - val colorScheme = when { - dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { - val context = LocalContext.current - if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) - } - - darkTheme -> DarkColorScheme - else -> LightColorScheme - } - MaterialTheme( - colorScheme = colorScheme, + colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, typography = Typography, - content = content + content = content, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Type.kt b/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Type.kt index 18f8311..4c22eaf 100644 --- a/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Type.kt +++ b/app/src/main/java/com/inseong/compose_git_grass/ui/theme/Type.kt @@ -6,29 +6,42 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -// Set of Material typography styles to start with val Typography = Typography( - bodyLarge = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Normal, - fontSize = 16.sp, - lineHeight = 24.sp, - letterSpacing = 0.5.sp - ) - /* Other default text styles to override + headlineMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 28.sp, + lineHeight = 34.sp, + letterSpacing = (-0.3).sp, + ), titleLarge = TextStyle( - fontFamily = FontFamily.Default, + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + lineHeight = 26.sp, + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + lineHeight = 22.sp, + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp, + ), + bodySmall = TextStyle( + fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Normal, - fontSize = 22.sp, - lineHeight = 28.sp, - letterSpacing = 0.sp + fontSize = 12.sp, + lineHeight = 18.sp, ), - labelSmall = TextStyle( - fontFamily = FontFamily.Default, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, + labelMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, lineHeight = 16.sp, - letterSpacing = 0.5.sp - ) - */ -) \ No newline at end of file + ), +) diff --git a/app/src/test/java/com/inseong/compose_git_grass/data/ShowcaseMetricsTest.kt b/app/src/test/java/com/inseong/compose_git_grass/data/ShowcaseMetricsTest.kt new file mode 100644 index 0000000..f6ad967 --- /dev/null +++ b/app/src/test/java/com/inseong/compose_git_grass/data/ShowcaseMetricsTest.kt @@ -0,0 +1,42 @@ +package com.inseong.compose_git_grass.data + +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Test + +class ShowcaseMetricsTest { + + @Test + fun `calculateShowcaseMetrics counts only positive values inside range`() { + val start = LocalDate.of(2025, 1, 1) + val end = LocalDate.of(2025, 1, 3) + val result = calculateShowcaseMetrics( + contributions = mapOf( + start.minusDays(1) to 100, + start to 3, + start.plusDays(1) to -5, + end to 7, + end.plusDays(1) to 100, + ), + startDate = start, + endDate = end, + ) + + assertEquals(10L, result.totalContributions) + assertEquals(2, result.activeDays) + } + + @Test + fun `calculateShowcaseMetrics normalizes a reversed range`() { + val start = LocalDate.of(2025, 1, 1) + val end = LocalDate.of(2025, 1, 3) + val result = calculateShowcaseMetrics( + contributions = mapOf(start.plusDays(1) to 4), + startDate = end, + endDate = start, + ) + + assertEquals(4L, result.totalContributions) + assertEquals(1, result.activeDays) + } +} diff --git a/library/build.gradle.kts b/library/build.gradle.kts index eb34cfb..1e743a4 100644 --- a/library/build.gradle.kts +++ b/library/build.gradle.kts @@ -125,7 +125,7 @@ dependencies { mavenPublishing { publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) signAllPublications() - coordinates("io.github.ois0886", "compose-git-grass", "1.1.1") + coordinates("io.github.ois0886", "compose-git-grass", "1.2.0") pom { name.set("Compose Git Grass") diff --git a/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt b/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt index cbf9df4..63ea18a 100644 --- a/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt +++ b/library/src/androidTest/java/com/inseong/gitgrass/GrassCellTest.kt @@ -1,5 +1,10 @@ package com.inseong.gitgrass +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.longClick import androidx.compose.ui.test.onNodeWithContentDescription @@ -98,4 +103,44 @@ class GrassCellTest { composeTestRule.onNodeWithContentDescription("$date: $count") .assertExists() } + + @Test + fun gitGrass_selectedCell_exposesSelectedSemantics() { + val date = LocalDate.of(2025, 6, 15) + + composeTestRule.setContent { + GitGrass( + contributions = mapOf(date to 4), + startDate = date, + endDate = date, + selection = GitGrassSelection(date = date), + showYearLabel = false, + showWeekLabels = false, + showMonthLabels = false, + showLegend = false, + ) + } + + composeTestRule.onNodeWithContentDescription("$date: 4") + .assertIsSelected() + } + + @Test + fun grassCell_longClickOnly_hasNoClickAction() { + val description = "Long-click only" + + composeTestRule.setContent { + GrassCell( + size = GitGrassDefaults.cellSize, + contentDescriptionText = description, + clickLabelText = null, + onClick = null, + onLongClick = {}, + ) + } + + composeTestRule.onNodeWithContentDescription(description) + .assertHasNoClickAction() + .assert(SemanticsMatcher.keyIsDefined(SemanticsActions.OnLongClick)) + } } diff --git a/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt b/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt index fba23c5..e609fff 100644 --- a/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt +++ b/library/src/androidTest/java/com/inseong/gitgrass/LabelRenderingTest.kt @@ -1,6 +1,5 @@ package com.inseong.gitgrass -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 @@ -14,25 +13,15 @@ class LabelRenderingTest { val composeTestRule = createComposeRule() @Test - fun monthRow_rendersVisibleMonthLabels() { - val days = generateDayList( - LocalDate.of(2025, 1, 1), - LocalDate.of(2025, 3, 31), - ) - val grid = buildGrid(days) - val monthPositions = createMonthLabels(grid) - + fun gitGrass_rendersVisibleMonthLabelsInsideGrid() { composeTestRule.setContent { - MonthRow( - weekCount = grid.size, - monthPositions = monthPositions, - monthLabels = GitGrassDefaults.monthLabels, - cellSize = GitGrassDefaults.cellSize, - cellSpacing = GitGrassDefaults.cellSpacing, - fontSize = GitGrassDefaults.labelFontSize, - textColor = GitGrassDefaults.colors().text, - scrollState = rememberLazyListState(), - weekLabelWidth = GitGrassDefaults.weekLabelWidth, + GitGrass( + contributions = emptyMap(), + startDate = LocalDate.of(2025, 1, 1), + endDate = LocalDate.of(2025, 1, 15), + showYearLabel = false, + showWeekLabels = false, + showLegend = false, ) } @@ -57,4 +46,24 @@ class LabelRenderingTest { composeTestRule.onNodeWithText("Fri").assertIsDisplayed() composeTestRule.onNodeWithText("Sun").assertIsDisplayed() } + + @Test + fun gitGrass_defaultWeekLabels_followSundayWeekStart() { + val date = LocalDate.of(2025, 1, 5) + + composeTestRule.setContent { + GitGrass( + contributions = emptyMap(), + startDate = date, + endDate = date.plusDays(6), + weekStartDay = java.time.DayOfWeek.SUNDAY, + showYearLabel = false, + showMonthLabels = false, + showLegend = false, + ) + } + + composeTestRule.onNodeWithText("Sun").assertIsDisplayed() + composeTestRule.onNodeWithText("Mon").assertDoesNotExist() + } } diff --git a/library/src/main/java/com/inseong/gitgrass/GitGrass.kt b/library/src/main/java/com/inseong/gitgrass/GitGrass.kt index 5e69b33..c8423f7 100644 --- a/library/src/main/java/com/inseong/gitgrass/GitGrass.kt +++ b/library/src/main/java/com/inseong/gitgrass/GitGrass.kt @@ -57,6 +57,7 @@ data class GitGrassStreakInfo( * @param colors Color scheme for cells and labels. See [GitGrassDefaults.colors] and [GitGrassDefaults.darkColors]. * @param monthLabels Month name strings indexed by [LocalDate.getMonthValue] (index 0 unused). * @param weekLabels Weekday name strings in order starting from [weekStartDay] (size 7). + * The default automatically follows [weekStartDay]. * @param cellSize Width and height of each day cell. * @param cellSpacing Gap between cells and between week columns. * @param cellCornerRadius Corner radius for each cell's rounded rectangle. @@ -75,6 +76,8 @@ data class GitGrassStreakInfo( * @param cellClickLabel Formats the accessibility click label for each cell. * Receives the cell's date. Default: `"$date details"`. * @param levelOf Maps a contribution count to a color level index (0 = empty, 1+ = [GitGrassColors.levels]). + * @param selection Optional controlled cell selection. The graph only renders this value and never + * changes it internally. Use [onCellClick] to update application state. * @param onCellClick Optional callback invoked when a cell is tapped, receiving the date and count. * @param onCellLongClick Optional callback invoked when a cell is long-pressed, receiving the date and count. */ @@ -87,7 +90,7 @@ fun GitGrass( weekStartDay: DayOfWeek = DayOfWeek.MONDAY, colors: GitGrassColors = GitGrassDefaults.colors(), monthLabels: List = GitGrassDefaults.monthLabels, - weekLabels: List = GitGrassDefaults.weekLabels, + weekLabels: List = GitGrassDefaults.weekLabelsFor(weekStartDay), cellSize: Dp = GitGrassDefaults.cellSize, cellSpacing: Dp = GitGrassDefaults.cellSpacing, cellCornerRadius: Dp = GitGrassDefaults.cellCornerRadius, @@ -106,6 +109,7 @@ fun GitGrass( }, cellClickLabel: (date: LocalDate) -> String = { date -> "$date details" }, levelOf: (count: Int) -> Int = GitGrassDefaults.levelThresholds, + selection: GitGrassSelection? = null, onCellClick: ((date: LocalDate, count: Int) -> Unit)? = null, onCellLongClick: ((date: LocalDate, count: Int) -> Unit)? = null, ) { @@ -126,8 +130,8 @@ fun GitGrass( if (showStreak) calculateStreak(safeContributions, days, safeEnd) else null } val cellShape = remember(cellCornerRadius) { RoundedCornerShape(cellCornerRadius) } - val hasCellActions = onCellClick != null || onCellLongClick != null - val cellClickLabelKey = if (hasCellActions) cellClickLabel else null + val includeClickLabels = onCellClick != null + val cellClickLabelKey = if (includeClickLabels) cellClickLabel else null val renderGrid = remember( grid, safeContributions, @@ -135,7 +139,7 @@ fun GitGrass( levelOf, cellContentDescription, cellClickLabelKey, - hasCellActions, + includeClickLabels, ) { buildRenderGrid( grid = grid, @@ -144,7 +148,15 @@ fun GitGrass( levelOf = levelOf, cellContentDescription = cellContentDescription, cellClickLabel = cellClickLabel, - includeClickLabels = hasCellActions, + includeClickLabels = includeClickLabels, + ) + } + + val resolvedSelection = remember(selection, colors.text, cellSize) { + resolveSelection( + selection = selection, + fallbackOutlineColor = colors.text, + cellSize = cellSize, ) } @@ -159,17 +171,14 @@ fun GitGrass( .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) - } + scrollState.scrollToItem( + calculateInitialScrollIndex( + weekCount = renderGrid.size, + viewportWidthPx = viewportWidth, + cellSizePx = cellSizePx, + cellSpacingPx = cellSpacingPx, + ), + ) } val weekLabelWidth = if (showWeekLabels) GitGrassDefaults.weekLabelWidth else 0.dp @@ -188,39 +197,41 @@ fun GitGrass( Spacer(modifier = Modifier.height(GitGrassDefaults.yearLabelBottomSpacing)) } - if (showMonthLabels) { - MonthRow( - weekCount = grid.size, - monthPositions = monthPositions, - monthLabels = monthLabels, - cellSize = cellSize, - cellSpacing = cellSpacing, - fontSize = labelFontSize, - textColor = colors.text, - scrollState = scrollState, - weekLabelWidth = weekLabelWidth, - ) - Spacer(modifier = Modifier.height(GitGrassDefaults.monthRowBottomSpacing)) - } - Row { if (showWeekLabels) { - WeekLabelColumn( - weekLabels = weekLabels, - cellSize = cellSize, - cellSpacing = cellSpacing, - fontSize = labelFontSize, - textColor = colors.text, - modifier = Modifier.width(weekLabelWidth), - ) + Column(modifier = Modifier.width(weekLabelWidth)) { + if (showMonthLabels) { + MonthLabelSlot( + text = "", + slotWidth = weekLabelWidth, + fontSize = labelFontSize, + textColor = colors.text, + ) + Spacer(modifier = Modifier.height(GitGrassDefaults.monthRowBottomSpacing)) + } + + WeekLabelColumn( + weekLabels = weekLabels, + cellSize = cellSize, + cellSpacing = cellSpacing, + fontSize = labelFontSize, + textColor = colors.text, + ) + } } GrassGridContent( renderGrid = renderGrid, + monthPositions = monthPositions, + monthLabels = monthLabels, + showMonthLabels = showMonthLabels, cellSize = cellSize, cellSpacing = cellSpacing, cellCornerRadius = cellCornerRadius, + labelFontSize = labelFontSize, + textColor = colors.text, scrollState = scrollState, + selection = resolvedSelection, 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 dc68cc6..efc252d 100644 --- a/library/src/main/java/com/inseong/gitgrass/GitGrassComponents.kt +++ b/library/src/main/java/com/inseong/gitgrass/GitGrassComponents.kt @@ -3,10 +3,13 @@ package com.inseong.gitgrass import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures 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.height @@ -25,9 +28,14 @@ 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.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -53,60 +61,28 @@ internal fun YearLabel( ) } -/** - * Horizontal row of month abbreviations that scrolls in sync with the grid. - * - * Uses the same column layout as [GrassGridContent] — each week is a fixed-width - * [cellSize] slot with [cellSpacing] gaps — ensuring pixel-perfect alignment. - * The [scrollState] is shared with the grid (scrolling disabled here) so it - * follows the grid's scroll position exactly. - */ +/** One month-label slot rendered inside the same lazy item as its week column. */ @Composable -internal fun MonthRow( - weekCount: Int, - monthPositions: MonthPositions, - monthLabels: List, - cellSize: Dp, - cellSpacing: Dp, +internal fun MonthLabelSlot( + text: String, + slotWidth: Dp, fontSize: TextUnit, textColor: Color, - scrollState: LazyListState, - weekLabelWidth: Dp, ) { - val labelMap = remember(monthPositions) { - monthPositions.associate { (weekIndex, month) -> weekIndex to month } - } + val placeholder = text.ifEmpty { "\u00A0" } + val textModifier = if (text.isEmpty()) Modifier.clearAndSetSemantics { } else Modifier - Row { - if (weekLabelWidth.value > 0f) { - Spacer(modifier = Modifier.width(weekLabelWidth)) - } - - LazyRow( - state = scrollState, - userScrollEnabled = false, - horizontalArrangement = Arrangement.spacedBy(cellSpacing), - ) { - 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) { - val label = monthLabels.getOrElse(monthNumber) { "" } - if (label.isNotEmpty()) { - BasicText( - text = label, - softWrap = false, - style = TextStyle(fontSize = fontSize, color = textColor), - ) - } - } - } - } - } + Box( + modifier = Modifier + .width(slotWidth) + .wrapContentSize(unbounded = true, align = Alignment.CenterStart), + ) { + BasicText( + text = placeholder, + modifier = textModifier, + softWrap = false, + style = TextStyle(fontSize = fontSize, color = textColor), + ) } } @@ -147,15 +123,26 @@ internal fun WeekLabelColumn( @Composable internal fun GrassGridContent( renderGrid: RenderGrid, + monthPositions: MonthPositions, + monthLabels: List, + showMonthLabels: Boolean, cellSize: Dp, cellSpacing: Dp, cellCornerRadius: Dp, + labelFontSize: TextUnit, + textColor: Color, scrollState: LazyListState, + selection: GitGrassSelection?, onCellClick: ((LocalDate, Int) -> Unit)?, onCellLongClick: ((LocalDate, Int) -> Unit)?, ) { + val labelMap = remember(monthPositions) { + monthPositions.associate { (weekIndex, month) -> weekIndex to month } + } + LazyRow( state = scrollState, + contentPadding = PaddingValues(end = cellSize + cellSpacing), horizontalArrangement = Arrangement.spacedBy(cellSpacing), ) { items( @@ -163,14 +150,31 @@ internal fun GrassGridContent( key = { weekIndex -> weekIndex }, contentType = { "grass-week" }, ) { weekIndex -> - GrassWeekColumn( - week = renderGrid[weekIndex], - cellSize = cellSize, - cellSpacing = cellSpacing, - cellCornerRadius = cellCornerRadius, - onCellClick = onCellClick, - onCellLongClick = onCellLongClick, - ) + Column { + if (showMonthLabels) { + val monthNumber = labelMap[weekIndex] + val monthLabel = monthNumber?.let { month -> + monthLabels.getOrElse(month) { "" } + }.orEmpty() + MonthLabelSlot( + text = monthLabel, + slotWidth = cellSize, + fontSize = labelFontSize, + textColor = textColor, + ) + Spacer(modifier = Modifier.height(GitGrassDefaults.monthRowBottomSpacing)) + } + + GrassWeekColumn( + week = renderGrid[weekIndex], + cellSize = cellSize, + cellSpacing = cellSpacing, + cellCornerRadius = cellCornerRadius, + selection = selection, + onCellClick = onCellClick, + onCellLongClick = onCellLongClick, + ) + } } } } @@ -182,6 +186,7 @@ internal fun GrassWeekColumn( cellSize: Dp, cellSpacing: Dp, cellCornerRadius: Dp, + selection: GitGrassSelection?, onCellClick: ((LocalDate, Int) -> Unit)?, onCellLongClick: ((LocalDate, Int) -> Unit)?, ) { @@ -203,6 +208,23 @@ internal fun GrassWeekColumn( size = Size(cellSizePx, cellSizePx), cornerRadius = CornerRadius(cornerRadiusPx, cornerRadiusPx), ) + + if (selection?.date == cell.date) { + val strokeWidthPx = selection.outlineWidth.toPx() + .coerceIn(0f, cellSizePx.coerceAtLeast(0f)) + if (strokeWidthPx > 0f) { + val inset = strokeWidthPx / 2f + val selectedSize = (cellSizePx - strokeWidthPx).coerceAtLeast(0f) + val selectedRadius = (cornerRadiusPx - inset).coerceAtLeast(0f) + drawRoundRect( + color = selection.outlineColor, + topLeft = Offset(inset, top + inset), + size = Size(selectedSize, selectedSize), + cornerRadius = CornerRadius(selectedRadius, selectedRadius), + style = Stroke(width = strokeWidthPx), + ) + } + } } } } @@ -218,6 +240,7 @@ internal fun GrassWeekColumn( size = cellSize, contentDescriptionText = cell.contentDescription, clickLabelText = cell.clickLabel, + isSelected = selection?.date == cell.date, onClick = onCellClick?.let { callback -> { callback(cell.date, cell.count) } }, onLongClick = onCellLongClick?.let { callback -> { callback(cell.date, cell.count) } }, ) @@ -241,6 +264,7 @@ internal fun GrassCell( size: Dp, contentDescriptionText: String, clickLabelText: String?, + isSelected: Boolean = false, onClick: (() -> Unit)?, onLongClick: (() -> Unit)? = null, ) { @@ -249,22 +273,48 @@ internal fun GrassCell( .size(size) .semantics { contentDescription = contentDescriptionText + if (isSelected) { + selected = true + } if (hasActions) { role = Role.Button } } - Box( - modifier = if (hasActions) { + val interactionModifier = when { + onClick != null && onLongClick != null -> { baseModifier.combinedClickable( - onClick = onClick ?: {}, + onClick = onClick, onLongClick = onLongClick, onClickLabel = clickLabelText, ) - } else { + } + + onClick != null -> { + baseModifier.clickable( + role = Role.Button, + onClickLabel = clickLabelText, + onClick = onClick, + ) + } + + onLongClick != null -> { baseModifier - }, - ) + .pointerInput(onLongClick) { + detectTapGestures(onLongPress = { onLongClick() }) + } + .semantics { + onLongClick { + onLongClick() + true + } + } + } + + else -> baseModifier + } + + Box(modifier = interactionModifier) } /** Displays max and current streak counts side by side. */ diff --git a/library/src/main/java/com/inseong/gitgrass/GitGrassDefaults.kt b/library/src/main/java/com/inseong/gitgrass/GitGrassDefaults.kt index d2674dd..91ffda7 100644 --- a/library/src/main/java/com/inseong/gitgrass/GitGrassDefaults.kt +++ b/library/src/main/java/com/inseong/gitgrass/GitGrassDefaults.kt @@ -77,6 +77,21 @@ object GitGrassDefaults { /** English weekday labels, Monday-first order. */ val weekLabels: List = listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") + private val weekLabelsByStartDay: Map> = + DayOfWeek.entries.associateWith { weekStartDay -> + weekDaysOrdered(weekStartDay).map { day -> weekLabels[day.value - 1] } + } + + /** + * Returns the default English weekday labels ordered from [weekStartDay]. + * + * Unlike [localizedWeekLabels], this returns cached labels and is suitable for a + * composable default argument. + */ + fun weekLabelsFor(weekStartDay: DayOfWeek = DayOfWeek.MONDAY): List { + return weekLabelsByStartDay.getValue(weekStartDay) + } + /** * Returns localized month labels using the device's default locale. * @@ -109,6 +124,9 @@ object GitGrassDefaults { val cellSpacing: Dp = 3.dp val cellCornerRadius: Dp = 2.dp + /** Default inset outline width for a selected contribution cell. */ + val selectionOutlineWidth: Dp = 2.dp + val labelFontSize: TextUnit = 10.sp val yearLabelFontSize: TextUnit = 13.sp diff --git a/library/src/main/java/com/inseong/gitgrass/GitGrassSelection.kt b/library/src/main/java/com/inseong/gitgrass/GitGrassSelection.kt new file mode 100644 index 0000000..48995ab --- /dev/null +++ b/library/src/main/java/com/inseong/gitgrass/GitGrassSelection.kt @@ -0,0 +1,41 @@ +package com.inseong.gitgrass + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import java.time.LocalDate + +/** + * Controlled selection shown on top of a [GitGrass] contribution cell. + * + * [GitGrass] never changes this value internally. Update the selection from the + * `onCellClick` callback or other application state and pass the new value back. + * + * @param date Date to highlight. Dates outside the displayed range are ignored. + * @param outlineColor Inset outline color. [Color.Unspecified] uses [GitGrassColors.text]. + * @param outlineWidth Inset outline width. Values are safely clamped to the selected cell size. + */ +@Immutable +data class GitGrassSelection( + val date: LocalDate, + val outlineColor: Color = Color.Unspecified, + val outlineWidth: Dp = GitGrassDefaults.selectionOutlineWidth, +) + +internal fun resolveSelection( + selection: GitGrassSelection?, + fallbackOutlineColor: Color, + cellSize: Dp, +): GitGrassSelection? { + selection ?: return null + + val maxOutlineWidth = (cellSize / 2).coerceAtLeast(Dp.Hairline) + return selection.copy( + outlineColor = if (selection.outlineColor == Color.Unspecified) { + fallbackOutlineColor + } else { + selection.outlineColor + }, + outlineWidth = selection.outlineWidth.coerceIn(Dp.Hairline, maxOutlineWidth), + ) +} diff --git a/library/src/main/java/com/inseong/gitgrass/GridUtils.kt b/library/src/main/java/com/inseong/gitgrass/GridUtils.kt index 559f8fd..9e1c19a 100644 --- a/library/src/main/java/com/inseong/gitgrass/GridUtils.kt +++ b/library/src/main/java/com/inseong/gitgrass/GridUtils.kt @@ -108,19 +108,26 @@ internal fun weekDaysOrdered(weekStartDay: DayOfWeek): List { * follows [java.time.LocalDate.getMonthValue] (1=Jan, 12=Dec). */ internal fun createMonthLabels(grid: Grid): MonthPositions { - val result = mutableListOf>() - var lastMonth = -1 + if (grid.isEmpty()) return emptyList() + + val positions = linkedMapOf() + val firstVisibleWeek = grid.indexOfFirst { week -> week.any { it != null } } + if (firstVisibleWeek == -1) return emptyList() + + val firstVisibleDay = grid[firstVisibleWeek].firstNotNullOf { it } + positions[firstVisibleWeek] = firstVisibleDay.monthValue for ((weekIndex, week) in grid.withIndex()) { - val firstDay = week.firstNotNullOfOrNull { it } ?: continue - val month = firstDay.monthValue - if (month != lastMonth) { - result.add(weekIndex to month) - lastMonth = month + val firstDayOfMonth = week.firstNotNullOfOrNull { day -> + day?.takeIf { it.dayOfMonth == 1 } + } + if (firstDayOfMonth != null) { + // A new month wins when the first visible week contains two months. + positions[weekIndex] = firstDayOfMonth.monthValue } } - return result + return positions.map { (weekIndex, month) -> weekIndex to month } } /** @@ -137,6 +144,30 @@ internal fun formatYearLabel(start: LocalDate, end: LocalDate): String { } } +/** + * Returns the first whole week column to show while keeping the newest week visible. + * + * Snapping to a whole item avoids clipping a month label at the leading viewport edge. + */ +internal fun calculateInitialScrollIndex( + weekCount: Int, + viewportWidthPx: Int, + cellSizePx: Int, + cellSpacingPx: Int, +): Int { + if (weekCount <= 0 || viewportWidthPx <= 0) return 0 + + val itemStridePx = cellSizePx + cellSpacingPx + if (itemStridePx <= 0) return 0 + + val visibleWeekCount = ( + ((viewportWidthPx + cellSpacingPx) / itemStridePx) - MONTH_LABEL_GUARD_WEEKS + ).coerceAtLeast(1) + return (weekCount - visibleWeekCount).coerceAtLeast(0) +} + +private const val MONTH_LABEL_GUARD_WEEKS = 1 + /** * Calculates max and current contribution streaks in a single forward pass. * diff --git a/library/src/test/java/com/inseong/gitgrass/GitGrassDefaultsTest.kt b/library/src/test/java/com/inseong/gitgrass/GitGrassDefaultsTest.kt index b039e26..aecb9f5 100644 --- a/library/src/test/java/com/inseong/gitgrass/GitGrassDefaultsTest.kt +++ b/library/src/test/java/com/inseong/gitgrass/GitGrassDefaultsTest.kt @@ -162,4 +162,25 @@ class GitGrassDefaultsTest { assertEquals("Mon", GitGrassDefaults.weekLabels[0]) assertEquals("Sun", GitGrassDefaults.weekLabels[6]) } + + @Test + fun `weekLabelsFor Sunday-start returns Sunday first`() { + val labels = GitGrassDefaults.weekLabelsFor(DayOfWeek.SUNDAY) + + assertEquals("Sun", labels.first()) + assertEquals("Sat", labels.last()) + } + + @Test + fun `weekLabelsFor supports every week start day`() { + for (weekStartDay in DayOfWeek.entries) { + val labels = GitGrassDefaults.weekLabelsFor(weekStartDay) + + assertEquals(7, labels.size) + assertEquals( + weekStartDay.getDisplayName(java.time.format.TextStyle.SHORT, Locale.ENGLISH), + labels.first(), + ) + } + } } diff --git a/library/src/test/java/com/inseong/gitgrass/GitGrassSelectionTest.kt b/library/src/test/java/com/inseong/gitgrass/GitGrassSelectionTest.kt new file mode 100644 index 0000000..5e8bd1e --- /dev/null +++ b/library/src/test/java/com/inseong/gitgrass/GitGrassSelectionTest.kt @@ -0,0 +1,67 @@ +package com.inseong.gitgrass + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import java.time.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class GitGrassSelectionTest { + + private val date = LocalDate.of(2025, 6, 15) + + @Test + fun `resolveSelection returns null for no selection`() { + assertNull( + resolveSelection( + selection = null, + fallbackOutlineColor = Color.Black, + cellSize = 12.dp, + ), + ) + } + + @Test + fun `resolveSelection uses text color for unspecified outline`() { + val result = resolveSelection( + selection = GitGrassSelection(date = date), + fallbackOutlineColor = Color.Magenta, + cellSize = 12.dp, + ) + + requireNotNull(result) + assertEquals(Color.Magenta, result.outlineColor) + } + + @Test + fun `resolveSelection preserves a custom outline color`() { + val result = resolveSelection( + selection = GitGrassSelection(date = date, outlineColor = Color.Blue), + fallbackOutlineColor = Color.Black, + cellSize = 12.dp, + ) + + requireNotNull(result) + assertEquals(Color.Blue, result.outlineColor) + } + + @Test + fun `resolveSelection clamps negative and oversized widths`() { + val negative = resolveSelection( + selection = GitGrassSelection(date = date, outlineWidth = (-2).dp), + fallbackOutlineColor = Color.Black, + cellSize = 12.dp, + ) + val oversized = resolveSelection( + selection = GitGrassSelection(date = date, outlineWidth = 20.dp), + fallbackOutlineColor = Color.Black, + cellSize = 12.dp, + ) + + requireNotNull(negative) + requireNotNull(oversized) + assertEquals(0.dp, negative.outlineWidth) + assertEquals(6.dp, oversized.outlineWidth) + } +} diff --git a/library/src/test/java/com/inseong/gitgrass/GridUtilsTest.kt b/library/src/test/java/com/inseong/gitgrass/GridUtilsTest.kt index 6e3ab0d..6b7fd3e 100644 --- a/library/src/test/java/com/inseong/gitgrass/GridUtilsTest.kt +++ b/library/src/test/java/com/inseong/gitgrass/GridUtilsTest.kt @@ -309,6 +309,39 @@ class GridUtilsTest { ) } + // ── calculateInitialScrollIndex ──────────────────────────────────── + + @Test + fun `calculateInitialScrollIndex keeps newest whole weeks visible`() { + val index = calculateInitialScrollIndex( + weekCount = 53, + viewportWidthPx = 300, + cellSizePx = 24, + cellSpacingPx = 6, + ) + + assertEquals(44, index) + } + + @Test + fun `calculateInitialScrollIndex returns zero when all weeks fit`() { + assertEquals( + 0, + calculateInitialScrollIndex( + weekCount = 5, + viewportWidthPx = 300, + cellSizePx = 24, + cellSpacingPx = 6, + ), + ) + } + + @Test + fun `calculateInitialScrollIndex handles invalid dimensions`() { + assertEquals(0, calculateInitialScrollIndex(10, 0, 24, 6)) + assertEquals(0, calculateInitialScrollIndex(10, 300, 0, 0)) + } + // ── calculateStreak ───────────────────────────────────────────────── @Test @@ -407,17 +440,30 @@ class GridUtilsTest { } @Test - fun `createMonthLabels month boundary transition at week start`() { + fun `createMonthLabels places a midweek month boundary in the containing week`() { val days = generateDayList( - LocalDate.of(2025, 1, 27), + LocalDate.of(2025, 1, 1), LocalDate.of(2025, 2, 9), ) val grid = buildGrid(days) val labels = createMonthLabels(grid) - val months = labels.map { it.second } - assertEquals(listOf(1, 2), months) - assertTrue(labels[1].first > labels[0].first) + val februaryWeek = grid.indexOfFirst { week -> + LocalDate.of(2025, 2, 1) in week + } + assertEquals(listOf(1, 2), labels.map { it.second }) + assertEquals(februaryWeek, labels.last().first) + } + + @Test + fun `createMonthLabels new month wins a collision in the first visible week`() { + val days = generateDayList( + LocalDate.of(2025, 1, 27), + LocalDate.of(2025, 2, 9), + ) + val labels = createMonthLabels(buildGrid(days)) + + assertEquals(listOf(0 to 2), labels) } // ── calculateStreak (additional) ───────────────────────────────── diff --git a/screenshots/sample-dark.png b/screenshots/sample-dark.png new file mode 100644 index 0000000..829dac0 Binary files /dev/null and b/screenshots/sample-dark.png differ diff --git a/screenshots/sample.png b/screenshots/sample.png index 38effb3..2112ec2 100644 Binary files a/screenshots/sample.png and b/screenshots/sample.png differ