diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 1f6915b0..6c5fc9a8 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -1,5 +1,5 @@ -# Android release — publishes kr.devslab:dds-android-tokens and -# kr.devslab:dds-compose to Maven Central. +# Android release — publishes kr.devslab:dds-android-tokens, +# kr.devslab:dds-compose and kr.devslab:dds-compose-testing to Maven Central. # # Triggered by a hand-pushed `android-v` tag, and by nothing else. # The web packages release through Changesets on every push to main @@ -50,7 +50,7 @@ jobs: - name: Build and test working-directory: android - run: ./gradlew testDebugUnitTest :dds-tokens:assembleRelease :dds-compose:assembleRelease --no-configuration-cache --stacktrace -PVERSION_NAME=${{ steps.ver.outputs.version }} + run: ./gradlew testDebugUnitTest :dds-tokens:assembleRelease :dds-compose:assembleRelease :dds-compose-testing:assembleRelease --no-configuration-cache --stacktrace -PVERSION_NAME=${{ steps.ver.outputs.version }} - name: Publish to Maven Central working-directory: android diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index b155e982..66117794 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -61,4 +61,4 @@ jobs: - uses: gradle/actions/setup-gradle@v5 - name: Unit tests, lint and release build working-directory: android - run: ./gradlew testDebugUnitTest :dds-tokens:lintRelease :dds-compose:lintRelease :dds-tokens:assembleRelease :dds-compose:assembleRelease :catalog:assembleDebug --stacktrace + run: ./gradlew testDebugUnitTest :dds-tokens:lintRelease :dds-compose:lintRelease :dds-compose-testing:lintRelease :dds-tokens:assembleRelease :dds-compose:assembleRelease :dds-compose-testing:assembleRelease :catalog:assembleDebug --stacktrace diff --git a/README.ko.md b/README.ko.md index 8279a457..724c07b7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -120,7 +120,7 @@ Source-Available License 1.0을 따른다. compatibility canary는 비공개· 때만 만든다 (명세 §8): `@devslab/dds-tokens` (✅ 2026-08-13) → `dds-css` v1 인벤토리 (Core 6종 ✅ 2026-08-13, +13종 ✅ 2026-08-15) → AskLinq 적용 → `dds-icons` (✅ 2026-08-15) → `dds-solid` + `site-kit` → 안드로이드 네이티브 값·테마·모션 -(0.6.0, 2026-11-12, Maven Central — 테마·모션·부품·아이콘). RN·Ionic 패키지는 그 제품이 생길 때. +(0.6.0, 2026-11-12, Maven Central — 테마·모션·부품·아이콘). 화면 틀과 `dds-compose-testing` 꾸러미를 담은 0.7.0은 만들었지만 **아직 Maven Central에 없다**. 정산나우가 폰에서 확인한 뒤에 올린다(D-052). RN·Ionic 패키지는 그 제품이 생길 때. ## 입력·선택·공유 달력 확장 diff --git a/README.md b/README.md index 413a4a6a..d2348f81 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ Priorities live in [`docs/backlog.md`](docs/backlog.md). Packages are created only when a consumer exists (spec §8): `@devslab/dds-tokens` (✅ 2026-08-13) → `dds-css` v1 inventory (Core 6 ✅ 2026-08-13, +13 ✅ 2026-08-15) → AskLinq adoption → `dds-icons` (✅ 2026-08-15) → `dds-solid` + `site-kit` → native Android values, theme and motion -(0.6.0, 2026-11-12, on Maven Central — theme, motion, components, icons). RN and Ionic packages only when such a product exists. +(0.6.0, 2026-11-12, on Maven Central — theme, motion, components, icons). 0.7.0, the screen frame and the `dds-compose-testing` artifact, is built but **not on Maven Central yet**: it waits for JeongsanNow's check on its phone (D-052). RN and Ionic packages only when such a product exists. ## Input, choice and shared calendar additions diff --git a/android/CHANGELOG.ko.md b/android/CHANGELOG.ko.md index f4105c88..f4fc5ee9 100644 --- a/android/CHANGELOG.ko.md +++ b/android/CHANGELOG.ko.md @@ -2,9 +2,93 @@ [English](CHANGELOG.md) · **한국어** -`kr.devslab:dds-android-tokens`와 `kr.devslab:dds-compose`는 한 버전을 쓰고, 웹 패키지와는 +`kr.devslab:dds-android-tokens`, `kr.devslab:dds-compose`, 그리고 0.7.0부터 `kr.devslab:dds-compose-testing`은 한 버전을 쓰고, 웹 패키지와는 버전이 따로다(D-048). 릴리스는 `android-v<버전>` 태그로 낸다. +## 0.7.0 — 아직 배포 전 + +화면 틀(D-052). 정산나우가 펼친 폰에서 찾았다. 화면마다 시작선이 달랐고(187, 147, 127dp, 또는 끝에서 +끝까지), 레일은 탭 화면에만 있었고, 상단 줄은 창 끝에서 본문은 가운데에서 시작했다. DDS에 숫자는 있는데 +그 숫자를 지키는 부품이 없었다. 이제 부품이 있다. 가이드 §13. + +**아직 Maven Central에 없다.** 정산나우가 로컬 빌드로 옮겨 폰의 창 세 가지(접힌 475×751, 펼친 세로 +704×933, 펼친 가로 933×704)에서 확인한 뒤에 배포한다. 그때까지는 이 레포에서 `./gradlew publishToMavenLocal`로 +만든다. + +**새로 생긴 것** + +- **`DdsAppFrame`** — 앱에 하나, 화면 전환기 바깥에 둔다. 창 폭이 메뉴를 정한다. 600dp 미만은 하단 탭(최상위 + 화면이 그린다), 600dp부터 레일, 840dp부터 레일과 영역의 고정 목록. `navigation = false`면 모두 끈다. 메뉴가 + 나타나거나 사라져도 내용은 제자리라, 접거나 돌려도 스크롤 위치와 입력한 글자가 남는다. +- **`DdsFrame`, `LocalDdsFrame`, `ddsFrame()`** — 틀이 정한 것. 화면은 재지 않고 이것을 읽는다. +- **`DdsWindowWidth`, `ddsWindowWidth()`, `DdsWindowWidth.gutter`** — 600과 840 기준의 Compact / Medium / + Expanded, 옆 여백 16 / 24 / 24. +- **`DdsScaffold(topBar, modifier, bottomTabs)`** — 틀 안의 화면 하나. 안쪽 여백이 이미 비켜 준 시스템 영역은 + 내용에서 쓴 것으로 표시해, 안에 둔 `DdsCenteredColumn`이나 `imePadding()`이 두 번 비우지 않는다. 예전 + `DdsScaffold(topBar, navItems, …)`는 그대로다. +- **`DdsBodyColumn`, `Modifier.ddsBody()`, `Modifier.ddsBodyLines()`, `DdsBodyDefaults`** — 화면마다 본문 기둥 + 하나. 여백 포함 680dp를 넘지 않고, 레일과 목록이 남긴 자리의 가운데에 선다. `paneTitle`은 화면이 들어올 때 + 화면 낭독기가 말할 화면 이름이다(`DdsCenteredColumn`도 같다). +- **`DdsTopBar(onBack, backEnabled, backDescription, columnMax)`** — 상단 줄이 본문의 선에 선다. `onBack`을 주면 + 화살표를 그리고 선에 맞춘다. **`ddsBarNeedsTwoLines()`** — 두 줄 판단. 틀 안에서는 `barTitles`로 창 전체에 + 대해 정한다(104dp). +- **`DdsCenteredColumn`, `DdsCenteredTitle`** — 한 가지 일만 하는 화면. 최대 400dp의 한 묶음이 시스템 줄과 + 키보드가 남긴 자리의 가운데에 선다. +- **`DdsPaneList`, `DdsPaneItem`** — 고정 목록의 줄. +- **`DdsTestTags`** — 부품이 스스로 다는 표시. +- **새 꾸러미 `kr.devslab:dds-compose-testing`** — `DdsFrameCheck(rule).look(name, kind)`와 + `.assertSameLines()`. 앱의 기기 테스트가 모든 화면이 한 시작선에 서는지, 창 폭과 화면 종류에 맞는 메뉴만 + 있는지 검사한다. Espresso 3.7.0을 함께 가져온다(Compose BOM이 가져오는 3.5.0은 Android 17에서 돌지 않는다). +- **새 토큰:** `DdsScreen.breakpointExpanded` 840, `bodyMax` 680, `listPane` 264, `centeredMax` 400. + `DdsBar.rail` 80, `topTall` 104. + +```kotlin +implementation("kr.devslab:dds-compose:0.7.0") +androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") +``` + +**버전만 올려도 달라지는 것** + +0.6.0으로 컴파일되던 코드는 0.7.0으로도 컴파일된다. 예외가 하나 있다. `DdsTopBar(title, Modifier, { 로고 }, +{ 동작 })`처럼 동작을 자리로 넘기는 호출은 컴파일되지 않는다. 네 번째 인자가 이제 `onBack`이기 때문이다. +`actions`를 이름으로 넘기거나 끝의 람다로 넘긴다. `DdsTopBar` 인자의 변경은 바이너리 호환도 깨진다. 0.6.0에 +맞춰 컴파일한 라이브러리가 `DdsTopBar`를 부르면 0.7.0에 맞춰 다시 빌드해야 한다. 아무것도 바꾸지 않아도 이렇게 +보인다. + +- **`DdsTopBar`.** 넓은 창에서 내용이 본문 기둥 폭(680dp)으로 가운데에 모인다. 아래 선과 바탕은 여전히 영역 + 전체 폭이다. 옆 여백이 창 폭 600부터 24가 된다(전에는 어디서나 16). `leading` 로고 뒤 제목이 시작선에서 + 40dp 뒤에서 시작한다. `leading`으로 뒤로 버튼을 넣던 코드는 그대로 동작하지만 선에 맞지 않으니 `onBack`으로 + 옮긴다. 동작 아이콘은 나란히 붙는다. 48dp 터치 영역이 맞닿는다(0.6.0에서는 12dp 떨어져 있었다). +- **`DdsBottomBar`, `DdsNavRail`.** 라벨이 한 줄에서 잘리지 않고 두 줄까지 쓰며, 탭 줄이나 레일 항목이 그만큼 + 높아진다. 레일 폭은 늘 80이다. 글자 200%에서 세 줄이 필요한 라벨은 두 번째 줄 뒤에서 잘린다. +- **가운데 묶음 화면이 움직이고 바탕을 칠한다.** `DdsCenteredColumn`이 본문 기둥처럼 화면과 함께 밀려 들어오고, + 자리 전체에 페이지 바탕(`bgDefault`)을 칠한다. 그래서 바뀌는 동안 모든 프레임에서 아래 화면을 덮는다. + `NavDisplay` 안에서도 그렇다. +- **고정 목록의 고르지 않은 줄**은 본문 색이 아니라 레일과 같은 회색(`textSecondary`)이다. + +정산나우가 자기 틀을 옮기면: 옆 여백이 20에서 접으면 16, 펼치면 24가 되고, 상단 줄 아래 선이 늘 보이고, +본문 위쪽 여백이 12나 20에서 16이나 24가 된다. 정산나우 부품과 DDS 부품의 대응은 인계 메모와 설계(§10)에 있다. + +**확인한 것** + +- 단위 테스트, 린트, 빌드: 통과. +- 기기 테스트 전체, 결과 파일로 읽었다. 실물 폰(SM-F971N, Android 17): 접은 화면(창 1248x1972) 170개 실행, 실패 0, + 오류 0, 건너뜀 2. 펼친 화면(2448x1848, 레일과 고정 목록이 나오는 넓은 창) 최종 코드에서 174개 실행, 실패 0, + 오류 0, 건너뜀 2. 건너뛴 둘은 그 폰에서 돌 수 없는 메뉴의 색 값 검사다(0.6.0과 같다). 틀 테스트는 테스트 + 안에서 창 세 가지로 크기를 고정한다. +- 카탈로그를 새 틀로 옮기고 Layout 쪽을 더해, 475×751, 704×933, 933×704로 고정한 에뮬레이터에서 보았다. 세 창 + 모두 밝은 화면, 933×704에서 어두운 화면, 475×751에서 가장 큰 글자, 475×751에서 키보드를 연 채. 캡처마다 본문의 + 시작선과 끝선을 그었고, 화살표, 제목, 마지막 아이콘, 본문 글자가 그 선에 섰다. +- `publishToMavenLocal`: 꾸러미 셋이 0.7.0으로 나온다. Espresso를 적은 POM은 `dds-compose-testing` 것뿐이다. + +**확인하지 못한 것** + +- 정산나우가 자기 폰에서 확인(배포 조건). +- 폰에서 카탈로그, 틀과 함께 하는 뒤로 밀기, 진짜 옆쪽 카메라 구멍(테스트 안에서만 설정), 오른쪽에서 왼쪽으로 + 쓰는 기기 언어, 틀 밖의 화면 분할, 따로 만든 앱에서 테스트 꾸러미 받기, 릴리스 워크플로가 세 번째 꾸러미를 + 올리는지, TalkBack. +- 옆 여백(시스템 줄·구멍)이 있을 때, 폭이 다른 본문일 때, 대화상자가 떠 있을 때의 `DdsFrameCheck`. + ## 0.6.0 — 2026-11-12 Qooroo가 앱 모양을 다시 정하면서 나온 것: 주황 면 위의 흰 글자, 종이색 페이지, 흰 면 보조 버튼, diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 9b2b4d1e..bebfa9f8 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -2,9 +2,110 @@ **English** · [한국어](CHANGELOG.ko.md) -`kr.devslab:dds-android-tokens` and `kr.devslab:dds-compose` share one version, +`kr.devslab:dds-android-tokens`, `kr.devslab:dds-compose` and (from 0.7.0) `kr.devslab:dds-compose-testing` share one version, separate from the web packages (D-048). Releases are cut by an `android-v` tag. +## 0.7.0 — not released yet + +The screen frame (D-052), from JeongsanNow on an unfolded phone: screens started on +different lines (187, 147, 127dp, or edge to edge), the rail was there on tab screens +only, and the top bar started at the window's edge while the body started in the middle. +DDS had the numbers and no part that kept them. Now it has the parts. Guide §13. + +**Not on Maven Central yet.** The release waits for JeongsanNow to move onto it from a +local build and check it on its phone in three windows (folded 475×751, unfolded upright +704×933, unfolded on its side 933×704). Until then it is built from this repository with +`./gradlew publishToMavenLocal`. + +**What is new** + +- **`DdsAppFrame`** — once per app, outside the screen switcher. The window's width + decides the menus: bottom tabs under 600dp (drawn by the top-level screens), a rail from + 600dp, a rail and the area's pinned list from 840dp. `navigation = false` turns them all + off. The content stays where it is when a menu appears or goes, so a fold or a rotation + keeps scroll positions and typed text. +- **`DdsFrame`, `LocalDdsFrame`, `ddsFrame()`** — what the frame decided, for a screen to + read instead of measuring. +- **`DdsWindowWidth`, `ddsWindowWidth()`, `DdsWindowWidth.gutter`** — Compact / Medium / + Expanded at 600 and 840; the gutter 16 / 24 / 24. +- **`DdsScaffold(topBar, modifier, bottomTabs)`** — one screen inside the frame. The system + insets its padding already covers are marked used for the content, so a `DdsCenteredColumn` + or an `imePadding()` inside does not pad them twice. The old `DdsScaffold(topBar, navItems, …)` + is unchanged. +- **`DdsBodyColumn`, `Modifier.ddsBody()`, `Modifier.ddsBodyLines()`, `DdsBodyDefaults`** + — one body column per screen, no wider than 680dp with its gutters, in the middle of what + the rail and the list leave. `paneTitle` names the screen for a screen reader when it arrives. +- **`DdsTopBar(onBack, backEnabled, backDescription, columnMax)`** — the bar stands on the + body's lines; `onBack` draws the arrow and lines it up. **`ddsBarNeedsTwoLines()`** — the + two-line decision; inside a frame `barTitles` makes it for the whole window (104dp). +- **`DdsCenteredColumn`, `DdsCenteredTitle`** — a screen with one thing to do: one block, + at most 400dp, in the middle of what the bars and the keyboard leave. Takes `paneTitle` too. +- **`DdsPaneList`, `DdsPaneItem`** — the rows of a pinned list. +- **`DdsTestTags`** — the tags the parts put on themselves. +- **New artifact `kr.devslab:dds-compose-testing`** — `DdsFrameCheck(rule).look(name, kind)` + and `.assertSameLines()`: an app's device tests check that every screen keeps one start + line and has only the menus its window and kind call for. It brings Espresso 3.7.0 (the + one the Compose BOM brings, 3.5.0, does not run on Android 17). +- **New tokens:** `DdsScreen.breakpointExpanded` 840, `bodyMax` 680, `listPane` 264, + `centeredMax` 400; `DdsBar.rail` 80, `topTall` 104. + +```kotlin +implementation("kr.devslab:dds-compose:0.7.0") +androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") +``` + +**If you move up** + +Code that compiled with 0.6.0 compiles with 0.7.0, with one exception: a `DdsTopBar` call +that passes its actions by position, `DdsTopBar(title, Modifier, { mark }, { actions })`, +does not, because the fourth parameter is now `onBack`. Pass `actions` by name or as the +trailing lambda. The change to `DdsTopBar`'s parameters is also binary-incompatible: a +library compiled against 0.6.0 that calls `DdsTopBar` must be rebuilt against 0.7.0. What +looks different without changing anything: + +- **`DdsTopBar`.** On a wide window its content gathers into the body column's width + (680dp) in the middle; the line under it and its background still span the area. The side + gutter is 24 from a 600dp window (was 16 everywhere). A title after a `leading` mark + starts 40dp after the start line. Code that passed a back button as `leading` still works + but is not on the lines; move it to `onBack`. Action icons sit side by side, their 48dp + touch areas touching; in 0.6.0 they were 12dp apart. +- **`DdsBottomBar`, `DdsNavRail`.** A label is no longer cut at one line; it takes up to + two, and the bar or the rail item grows with it. The rail is always 80 wide. At 200% font + a label that needs a third line is cut after the second. +- **A centered screen moves and paints.** `DdsCenteredColumn` slides in with the screen as a + body column does, and paints the page (`bgDefault`) behind its whole area, so it covers the + screen under it on every frame of a change, also in a `NavDisplay`. +- **The rows of a pinned list** that are not chosen are in the rail's grey (`textSecondary`), + not the body color. + +JeongsanNow, moving its own frame over: the gutter goes from 20 to 16 folded and 24 +unfolded, the line under the top bar is always shown, and the space above the body is 16 or +24 (was 12 or 20). The mapping from its parts to DDS's is in the hand-over note and the +design (spec §10). + +**Checked** + +- Unit tests, lint and build: green. +- Device tests, the whole suite, read from the result files, on the phone (SM-F971N, + Android 17): folded (window 1248x1972) 170 run, 0 failed, 0 errors, 2 skipped; unfolded + (2448x1848, the wide window with the rail and the pinned list) 174 run, 0 failed, 0 errors, + 2 skipped, at the final code. The skipped are the menu's two exact-color checks, which cannot run there (0.6.0). The frame + tests force the three windows inside the test. +- The catalog, on the new frame with a Layout page, on an emulator forced to 475×751, + 704×933 and 933×704: light at all three, dark at 933×704, the largest font at 475×751, + the keyboard open at 475×751. The body's start and end lines were drawn over every + capture; the arrow, the title, the last icon and the body text stand on them. +- `publishToMavenLocal`: three artifacts at 0.7.0. Only `dds-compose-testing`'s POM names + Espresso. + +**Not verified** + +- JeongsanNow on its phone (the condition for the release). +- The catalog on the phone; the back swipe with the frame; a real side cutout (set in tests + only); a right-to-left device language; split screen outside a frame; the testing artifact + from a separate app's build; the release workflow publishing the third artifact; TalkBack. +- `DdsFrameCheck` with side insets, with a body of another width, with a dialog open. + ## 0.6.0 — 2026-11-12 From Qooroo redrawing its app: white text on an orange face, a paper-colored page, a diff --git a/android/README.ko.md b/android/README.ko.md index 3ca54596..496e6c2f 100644 --- a/android/README.ko.md +++ b/android/README.ko.md @@ -8,7 +8,8 @@ | 모듈 | 좌표 | 내용 | | --- | --- | --- | | `dds-tokens` | `kr.devslab:dds-android-tokens` | 값만. Kotlin 상수와 XML 리소스. Compose 의존 없음 | -| `dds-compose` | `kr.devslab:dds-compose` | Material3 테마, 브랜드, 모션·불러오기 도우미, 부품, 아이콘 | +| `dds-compose` | `kr.devslab:dds-compose` | Material3 테마, 브랜드, 모션·불러오기 도우미, 부품, 아이콘, 화면 틀 | +| `dds-compose-testing` | `kr.devslab:dds-compose-testing` | 앱의 기기 테스트용. `DdsFrameCheck`가 앱 화면을 틀에 대고 잰다. Espresso 3.7.0을 함께 가져온다. 0.7.0부터 | | `catalog` | 배포하지 않음 | 모든 값과 도우미를 화면에 띄우는 확인용 앱 | 최소 Android 8(API 26), compileSdk 36, Kotlin 2.2.20, Compose BOM 2026.01.00. @@ -21,6 +22,17 @@ dependencies { } ``` +**Maven Central에 있는 것은 0.6.0이다.** 0.7.0(화면 틀, 가이드 §13, 그리고 `dds-compose-testing` 꾸러미)은 +만들었지만 아직 Maven Central에 없다. 정산나우가 폰에서 확인한 뒤에 올린다. 지금 쓰려면 로컬 저장소에 +만들어 넣고(아래) 이렇게 받는다. + +```kotlin +dependencies { + implementation("kr.devslab:dds-compose:0.7.0") + androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") +} +``` + ```kotlin setContent { DdsTheme { // 시스템의 밝은·어두운 설정을 따른다 @@ -91,7 +103,7 @@ adb -s <기기> shell am start -n kr.devslab.dds.catalog/.MainActivity \ --es theme dark --es page parts --ez large true ``` -`page`는 `colors`, `type`, `parts`, `motion`. 부품이나 값을 바꾸면 밝은·어두운, +`page`는 `colors`, `type`, `parts`, `motion`, `layout`. `--es demo body|bar|centered|wholewindow`는 Layout 시연을, `--ei item 2`는 Motion의 항목(들어간 화면)을 바로 연다. 부품이나 값을 바꾸면 밝은·어두운, 글자 배율 200%, 큰 글자 모드, "애니메이션 삭제"에서 직접 띄워 본다. ## 기기 테스트 @@ -99,7 +111,8 @@ adb -s <기기> shell am start -n kr.devslab.dds.catalog/.MainActivity \ `dds-compose/src/androidTest`는 화면 낭독기에 전달되는 것을 읽고(`ScreenReaderTest`), 시계를 멈춘 채 움직임의 중간 지점을 잰다(`MotionOnDeviceTest`, Navigation 3 안에서 재는 `NavDisplayOnDeviceTest` — Navigation 3은 테스트에만 쓰는 의존성이다 — 액티비티를 다시 만드는 `RebuiltWindowTest`, -그리고 걷히는 스플래시 덮개를 재는 `SplashOverlayOnDeviceTest`). 에뮬레이터나 폰이 필요해서 +그리고 걷히는 스플래시 덮개를 재는 `SplashOverlayOnDeviceTest`). `FrameOnDeviceTest`는 창을 세 가지(475×751, +704×933, 933×704)로 고정하고 화면 틀을 재고, `FrameCheckTest`는 `dds-compose-testing`의 검사를 잰다. 에뮬레이터나 폰이 필요해서 CI는 돌리지 않는다. 기기를 지정한다 — 지정하지 않으면 연결된 모든 기기에서 돈다. ``` @@ -108,7 +121,7 @@ ANDROID_SERIAL=<기기> ./gradlew :dds-compose:connectedDebugAndroidTest ## 버전과 배포 -- 웹 패키지와 **다른 버전 번호**를 쓴다. 두 좌표는 항상 같은 버전이다(`gradle.properties`의 `VERSION_NAME`). +- 웹 패키지와 **다른 버전 번호**를 쓴다. 좌표들(0.7.0부터 셋)은 항상 같은 버전이다(`gradle.properties`의 `VERSION_NAME`). - 토큰 이름을 지우거나 바꾸는 변경, 공개 API를 지우는 변경은 major(0.x 동안은 minor)다. - 배포는 `android-v<버전>` 태그를 올릴 때만 일어난다(`.github/workflows/android-release.yml`). DevsLab의 Java 라이브러리와 같은 시크릿 다섯 개가 이 저장소에 있어야 한다. diff --git a/android/README.md b/android/README.md index a9b60e96..c48fd352 100644 --- a/android/README.md +++ b/android/README.md @@ -8,7 +8,8 @@ repository root. The rules are in [`docs/mobile-native.md`](../docs/mobile-nativ | Module | Coordinate | Contents | | --- | --- | --- | | `dds-tokens` | `kr.devslab:dds-android-tokens` | Values only: Kotlin constants and XML resources. No Compose dependency | -| `dds-compose` | `kr.devslab:dds-compose` | Material3 theme, brand, motion and loading helpers, components, icons | +| `dds-compose` | `kr.devslab:dds-compose` | Material3 theme, brand, motion and loading helpers, components, icons, the screen frame | +| `dds-compose-testing` | `kr.devslab:dds-compose-testing` | For an app's device tests: `DdsFrameCheck` checks its screens against the frame. Brings Espresso 3.7.0. From 0.7.0 | | `catalog` | not published | An app that puts every value and helper on screen, for checking | Minimum Android 8 (API 26), compileSdk 36, Kotlin 2.2.20, Compose BOM 2026.01.00. @@ -21,6 +22,18 @@ dependencies { } ``` +**0.6.0 is the version on Maven Central.** 0.7.0 (the screen frame, guide §13, and the +`dds-compose-testing` artifact) is built but not on Maven Central yet: it waits for +JeongsanNow's check on its phone. To use it now, build it into the local repository (below), +then: + +```kotlin +dependencies { + implementation("kr.devslab:dds-compose:0.7.0") + androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") +} +``` + ```kotlin setContent { DdsTheme { // follows the system light/dark setting @@ -91,7 +104,7 @@ adb -s shell am start -n kr.devslab.dds.catalog/.MainActivity \ --es theme dark --es page parts --ez large true ``` -`page` is one of `colors`, `type`, `parts`, `motion`. After changing a value or +`page` is one of `colors`, `type`, `parts`, `motion`, `layout`. `--es demo body|bar|centered|wholewindow` opens a Layout demo; `--ei item 2` opens a Motion item (an inner screen). After changing a value or a helper, look at it in light and dark, at a 200% font scale, in large text mode, and with "Remove animations" on. @@ -101,7 +114,8 @@ and with "Remove animations" on. measures what moves part-way through, with the clock held (`MotionOnDeviceTest`, `NavDisplayOnDeviceTest` inside Navigation 3 — a test-only dependency — and `RebuiltWindowTest`, which rebuilds the activity; `SplashOverlayOnDeviceTest` for the -splash cover as it lifts). It needs an +splash cover as it lifts). `FrameOnDeviceTest` measures the screen frame in three forced windows (475×751, +704×933, 933×704), and `FrameCheckTest` the check of `dds-compose-testing`. It needs an emulator or a phone, so CI does not run it. Name the device — the task otherwise runs on every device that is connected: @@ -111,8 +125,8 @@ ANDROID_SERIAL= ./gradlew :dds-compose:connectedDebugAndroidTest ## Versions and releases -- Android has **its own version number**, apart from the web packages. The two - coordinates always share one version (`VERSION_NAME` in `gradle.properties`). +- Android has **its own version number**, apart from the web packages. The + coordinates (three from 0.7.0) always share one version (`VERSION_NAME` in `gradle.properties`). - Removing or renaming a token, or removing public API, is a major change (a minor one during 0.x). - A release happens only when an `android-v` tag is pushed (`.github/workflows/android-release.yml`). The repository needs the same five diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 086c7a89..3da83782 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -15,7 +15,8 @@ val ddsTokensVersion: String = groovy.json.JsonSlurper() .parse(rootDir.resolve("../packages/dds-tokens/package.json")) .let { (it as Map<*, *>)["version"] as String } -// Shared by the two published modules (kr.devslab:dds-android-tokens, kr.devslab:dds-compose). +// Shared by the three published modules (kr.devslab:dds-android-tokens, kr.devslab:dds-compose, +// kr.devslab:dds-compose-testing). subprojects { plugins.withId("com.vanniktech.maven.publish") { extensions.configure { diff --git a/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/LayoutPages.kt b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/LayoutPages.kt new file mode 100644 index 00000000..bcf88988 --- /dev/null +++ b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/LayoutPages.kt @@ -0,0 +1,197 @@ +package kr.devslab.dds.catalog + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import kr.devslab.dds.compose.component.DdsButton +import kr.devslab.dds.compose.component.DdsButtonVariant +import kr.devslab.dds.compose.component.DdsCard +import kr.devslab.dds.compose.component.DdsIconButton +import kr.devslab.dds.compose.component.DdsPaneItem +import kr.devslab.dds.compose.component.DdsPaneList +import kr.devslab.dds.compose.component.DdsScaffold +import kr.devslab.dds.compose.component.DdsSection +import kr.devslab.dds.compose.component.DdsTextField +import kr.devslab.dds.compose.component.DdsTopBar +import kr.devslab.dds.compose.component.DdsValueRow +import kr.devslab.dds.compose.icons.DdsIcons +import kr.devslab.dds.compose.layout.DdsBodyColumn +import kr.devslab.dds.compose.layout.DdsCenteredColumn +import kr.devslab.dds.compose.layout.DdsCenteredTitle +import kr.devslab.dds.compose.layout.ddsBody +import kr.devslab.dds.compose.layout.ddsFrame +import kr.devslab.dds.compose.layout.gutter +import kr.devslab.dds.compose.motion.ddsScreenContent +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsBar +import kr.devslab.dds.tokens.DdsScreen + +/** The demos of the Layout page: one per part of the screen frame. */ +enum class LayoutDemo(val title: String) { + Body("Body column"), + Bar("Top bar"), + Centered("Centered block"), + WholeWindow("Whole window"), +} + +/** + * The rows of the Layout list. The same list is pinned beside the demos on a wide window and is + * a screen of its own on a narrower one. + */ +@Composable +fun LayoutRows(selected: LayoutDemo?, onChoose: (LayoutDemo) -> Unit) { + DdsPaneList { + for (demo in LayoutDemo.entries) { + DdsPaneItem(demo.title, selected = demo == selected, onClick = { onChoose(demo) }) + } + } +} + +/** The pinned list, beside the demos on a wide window: its own top bar over the rows. */ +@Composable +fun LayoutPinnedList(selected: LayoutDemo?, onChoose: (LayoutDemo) -> Unit) { + DdsScaffold(topBar = { DdsTopBar(Page.Layout.title) }) { padding -> + Box(Modifier.padding(padding)) { LayoutRows(selected, onChoose) } + } +} + +/** + * The list as a screen, on a window too narrow to pin it: the Layout tab itself. It lays its own + * body out, so the rows stand on the same lines as a body column's text, and it slides in and says + * its name as a body column does. + */ +@Composable +fun LayoutListBody(onChoose: (LayoutDemo) -> Unit) { + Box(Modifier.fillMaxSize().ddsScreenContent(paneTitle = Page.Layout.title), contentAlignment = Alignment.TopCenter) { + Box(Modifier.ddsBody().fillMaxWidth()) { LayoutRows(selected = null, onChoose = onChoose) } + } +} + +/** + * One demo, as a screen. Beside the pinned list it has nowhere to go back to, so [onBack] is + * null there; opened over the list it goes back to it. + */ +@Composable +fun LayoutDemoScreen(demo: LayoutDemo, onBack: (() -> Unit)?) { + when (demo) { + LayoutDemo.Body -> DdsScaffold(topBar = { DdsTopBar(demo.title, onBack = onBack) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { BodyDemo(demo.title) } + } + LayoutDemo.Bar -> DdsScaffold( + topBar = { + DdsTopBar(demo.title, onBack = onBack) { + DdsIconButton(DdsIcons.Search, contentDescription = "Search", onClick = {}) + DdsIconButton(DdsIcons.MoreHorizontal, contentDescription = "More", onClick = {}) + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { BarDemo(demo.title, withArrow = onBack != null) } + } + LayoutDemo.Centered -> CenteredDemo(demo.title, onBack) + LayoutDemo.WholeWindow -> DdsScaffold(topBar = { DdsTopBar(demo.title, onBack = onBack) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { WholeWindowDemo(demo.title) } + } + } +} + +/** What the frame decided for this window, read as every screen reads it: from [ddsFrame]. */ +@Composable +private fun FrameReading() { + val frame = ddsFrame() + DdsCard { + Column { + DdsValueRow("Window width", frame.width.name) + DdsValueRow("Gutter", "${frame.width.gutter.value.toInt()}dp") + DdsValueRow("Rail", if (frame.rail) "${DdsBar.rail}dp" else "none") + DdsValueRow("Pinned list", if (frame.listPinned) "${DdsScreen.listPane}dp" else "none") + DdsValueRow("Top bar", if (frame.tallBar) "${DdsBar.topTall}dp, two lines" else "${DdsBar.top}dp, one line", divider = false) + } + } +} + +@Composable +private fun Note(text: String) { + Text(text, style = DdsTheme.typography.body2, color = DdsTheme.colors.textSecondary) +} + +@Composable +private fun BodyDemo(title: String) { + DdsBodyColumn(paneTitle = title) { + Note( + "One column per screen, no wider than ${DdsScreen.bodyMax}dp with its gutters, in the middle of what the " + + "rail and the pinned list leave. Every screen of a window starts on the same line.", + ) + DdsSection("This window") { FrameReading() } + DdsSection("The numbers") { + DdsCard { + Column { + DdsValueRow("Medium from", "${DdsScreen.breakpoint}dp") + DdsValueRow("Expanded from", "${DdsScreen.breakpointExpanded}dp") + DdsValueRow("Gutter, narrow / wide", "${DdsScreen.gutter} / ${DdsScreen.gutterWide}dp") + DdsValueRow("Body, widest", "${DdsScreen.bodyMax}dp") + DdsValueRow("Pinned list", "${DdsScreen.listPane}dp + 1dp line") + DdsValueRow("Centered block, widest", "${DdsScreen.centeredMax}dp", divider = false) + } + } + } + } +} + +@Composable +private fun BarDemo(title: String, withArrow: Boolean) { + val gutter = ddsFrame().width.gutter.value.toInt() + DdsBodyColumn(paneTitle = title) { + Note("The top bar stands on the body's lines. Its content is as wide as the body and sits in the same place.") + DdsCard { + Column { + if (withArrow) { + DdsValueRow("Back arrow's 24dp icon", "starts on the start line") + DdsValueRow("Title", "start line + 40dp") + } else { + // Beside the pinned list there is nowhere to go back to: no arrow, and the title moves to the line. + DdsValueRow("Title", "on the start line") + } + DdsValueRow("Last icon (24dp)", "ends on the end line") + DdsValueRow("Gutter of this window", "${gutter}dp", divider = false) + } + } + Note( + "A tab screen's mark and a detail screen's arrow put the title in the same place, so opening a screen " + + "does not move the title sideways. The touch area of each icon (48dp) reaches 12dp into the gutter.", + ) + } +} + +@Composable +private fun CenteredDemo(title: String, onBack: (() -> Unit)?) { + var number by rememberSaveable { mutableStateOf("") } + DdsCenteredColumn(paneTitle = title) { + DdsCenteredTitle("정산을\n시작합니다", description = "사업자 번호를 넣으면 거래처와 주문을 한곳에서 봅니다.") + DdsTextField(number, { number = it }, label = "사업자 번호", modifier = Modifier.fillMaxWidth(), placeholder = "000-00-00000") + DdsButton("시작하기", onClick = {}, modifier = Modifier.fillMaxWidth()) + if (onBack != null) { + DdsButton("Back to the list", onClick = onBack, modifier = Modifier.fillMaxWidth(), variant = DdsButtonVariant.Ghost) + } + } +} + +@Composable +private fun WholeWindowDemo(title: String) { + DdsBodyColumn(paneTitle = title) { + Note( + "The menus are off here (navigation = false): no rail, no pinned list, no bottom tabs. An app turns them " + + "off before sign-in and on a screen with one task, such as writing or scanning.", + ) + DdsSection("This window") { FrameReading() } + } +} diff --git a/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/MainActivity.kt b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/MainActivity.kt index 742089db..a5369343 100644 --- a/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/MainActivity.kt +++ b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/MainActivity.kt @@ -20,7 +20,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -40,9 +39,11 @@ import kr.devslab.dds.compose.component.DdsScaffold import kr.devslab.dds.compose.navigation.DdsBackHandler import kr.devslab.dds.compose.navigation.rememberDdsTabTrail import androidx.activity.compose.LocalActivity -import kr.devslab.dds.compose.component.DdsSize import kr.devslab.dds.compose.component.DdsTopBar import kr.devslab.dds.compose.icons.DdsIcons +import kr.devslab.dds.compose.layout.DdsAppFrame +import kr.devslab.dds.compose.layout.DdsWindowWidth +import kr.devslab.dds.compose.layout.ddsFrame import kr.devslab.dds.compose.motion.DdsMove import kr.devslab.dds.compose.motion.DdsScreenHost import kr.devslab.dds.compose.motion.DdsSplashHandoff @@ -63,7 +64,9 @@ import kr.devslab.dds.tokens.R as TokensR /** * Launch extras, so every state can be captured from adb without tapping: - * --es page colors|type|parts|motion first page + * --es page colors|type|parts|motion|layout first page + * --ei item 0..7 open a Motion item (an inner screen) + * --es demo body|bar|centered|wholewindow open a Layout demo * --es theme light|dark pin the theme (default: system) * --ez large true large text mode * --es brand ink a product brand, to see every brand place change @@ -97,6 +100,8 @@ class MainActivity : ComponentActivity() { val pinnedTheme = intent.getStringExtra("theme") val large = intent.getBooleanExtra("large", false) val inkBrand = intent.getStringExtra("brand") == "ink" + val firstDemo = LayoutDemo.entries.firstOrNull { it.name.equals(intent.getStringExtra("demo"), ignoreCase = true) } + val firstItem = if (intent.hasExtra("item")) intent.getIntExtra("item", 0).coerceIn(0, 7) else null setContent { val dark = when (pinnedTheme) { @@ -115,7 +120,7 @@ class MainActivity : ComponentActivity() { DdsTheme(darkTheme = dark, brand = if (inkBrand) InkBrand else rememberDdsBrand(), largeText = largeText) { CompositionLocalProvider(LocalDdsSplashHandoff provides handoff) { Box(Modifier.fillMaxSize()) { - Catalog(firstPage, largeText, onLargeText = { largeText = it }) + Catalog(firstPage, firstDemo, firstItem, largeText, onLargeText = { largeText = it }) DdsSplashOverlay( handoff = handoff, background = DdsTheme.colors.bgDefault, @@ -174,35 +179,52 @@ fun CatalogMark(size: Dp, modifier: Modifier = Modifier) { Box(modifier.size(size).clip(RoundedCornerShape(corner)).background(DdsTheme.colors.bgBrand)) } + enum class Page(val title: String, val icon: ImageVector) { Colors("Colors", DdsIcons.LayoutGrid), Type("Type", DdsIcons.Doc), Parts("Parts", DdsIcons.Tool), Motion("Motion", DdsIcons.Refresh), + Layout("Layout", DdsIcons.Menu), } +/** One screen of the catalog: a tab, and what was opened in it (a Motion item or a Layout demo). */ +private data class Shown(val page: Page, val detail: Int?, val demo: LayoutDemo?) + +/** Every name a top bar of the catalog shows, so the frame can tell whether one needs two lines. */ +private val BarTitles: List = + Page.entries.map { it.title } + (1..8).map { "Item $it" } + LayoutDemo.entries.map { it.title } + @Composable -private fun Catalog(firstPage: Page, largeText: Boolean, onLargeText: (Boolean) -> Unit) { +private fun Catalog(firstPage: Page, firstDemo: LayoutDemo?, firstItem: Int?, largeText: Boolean, onLargeText: (Boolean) -> Unit) { var page by rememberSaveable { mutableStateOf(firstPage) } // The Motion page has a second level, to show push and pop. - var detail by rememberSaveable { mutableStateOf(null) } + var detail by rememberSaveable { mutableStateOf(firstItem) } + // The Layout page's demo: opened over the list on a narrow window, beside it on a wide one. + var demo by rememberSaveable { mutableStateOf(firstDemo) } var move by remember { mutableStateOf(DdsMove.None) } + // Read outside the frame, from the window: the frame pins the list on the same width. + val listPinnable = ddsFrame().width == DdsWindowWidth.Expanded && page == Page.Layout && demo != LayoutDemo.WholeWindow val goBack = { move = DdsMove.Pop + if (page == Page.Layout) demo = null else detail = null + } + val openTab = { index: Int -> + move = DdsMove.Tab detail = null + demo = null + page = Page.entries[index] } - // Back goes back: the screen first, then the tab before; leaving the app is asked. + // Back goes back: the screen first, then the tab before; leaving the app is asked. Beside the + // pinned list a demo is not a step to go back from: back leaves the area. val trail = rememberDdsTabTrail(startTab = firstPage.ordinal) var askingToExit by rememberSaveable { mutableStateOf(false) } val activity = LocalActivity.current DdsBackHandler( trail = trail, - canPop = detail != null, + canPop = detail != null || (demo != null && !listPinnable), onPop = goBack, - onTab = { index -> - move = DdsMove.Tab - page = Page.entries[index] - }, + onTab = openTab, onAskToExit = { askingToExit = true }, enabled = !askingToExit, ) @@ -214,51 +236,78 @@ private fun Catalog(firstPage: Page, largeText: Boolean, onLargeText: (Boolean) dismiss = { DdsButton("취소", onClick = { askingToExit = false }, variant = DdsButtonVariant.Ghost) }, ) } + val choose = { chosen: LayoutDemo -> + // Beside the pinned list choosing swaps the screen; over a list that is a screen it opens one. + move = if (listPinnable) DdsMove.Tab else DdsMove.Push + demo = chosen + } - DdsScaffold( - topBar = { - DdsTopBar( - title = if (detail != null) "Item ${detail!! + 1}" else page.title, - leading = { - if (detail != null) { - DdsIconButton(DdsIcons.ArrowLeft, contentDescription = "Back", onClick = goBack) - } else { - CatalogMark(DdsSpace.s24.dp, Modifier.ddsSplashTarget(priority = 0)) - } - }, - actions = { - DdsButton( - text = if (largeText) "Aa large" else "Aa", - onClick = { onLargeText(!largeText) }, - variant = DdsButtonVariant.Ghost, - size = DdsSize.Sm, - ) - }, - ) - }, + DdsAppFrame( navItems = Page.entries.map { DdsNavItem(it.title, it.icon) }, selectedIndex = page.ordinal, onSelect = { index -> - if (index != page.ordinal || detail != null) { - move = DdsMove.Tab - detail = null - page = Page.entries[index] + if (index != page.ordinal || detail != null || demo != null) { + openTab(index) trail.visit(index) } }, - ) { padding -> + // The whole-window demo turns the menus off, as an app does before sign-in or for a scanner. + navigation = !(page == Page.Layout && demo == LayoutDemo.WholeWindow), + barTitles = BarTitles, + list = if (page == Page.Layout) ({ LayoutPinnedList(demo ?: LayoutDemo.Body, choose) }) else null, + ) { + val pinned = ddsFrame().listPinned + // Beside the pinned list there is always a demo to show: the first, until one is chosen. + val shown = Shown(page, detail, if (page == Page.Layout) demo ?: (if (pinned) LayoutDemo.Body else null) else null) // The screen before stays drawn underneath until the new one covers it. - DdsScreenHost(screen = page to detail, move = move, modifier = Modifier.padding(padding).fillMaxSize()) { (shownPage, shownDetail) -> + DdsScreenHost(screen = shown, move = move) { (shownPage, shownDetail, shownDemo) -> when { - shownPage == Page.Motion && shownDetail != null -> MotionDetail(shownDetail) - shownPage == Page.Colors -> ColorsPage() - shownPage == Page.Type -> TypePage() - shownPage == Page.Parts -> PartsPage() - else -> MotionPage(onOpen = { - move = DdsMove.Push - detail = it - }) + shownPage == Page.Motion && shownDetail != null -> InnerScreen("Item ${shownDetail + 1}", goBack) { MotionDetail(shownDetail) } + shownPage == Page.Layout && shownDemo != null -> LayoutDemoScreen(shownDemo, onBack = if (pinned) null else goBack) + else -> TabScreen(shownPage, largeText, onLargeText) { + when (shownPage) { + Page.Colors -> ColorsPage() + Page.Type -> TypePage() + Page.Parts -> PartsPage() + Page.Motion -> MotionPage(onOpen = { + move = DdsMove.Push + detail = it + }) + Page.Layout -> LayoutListBody(choose) + } + } } } } } + +/** A tab's own screen: the mark, the tab's name, the text size switch, and the bottom tabs on a phone. */ +@Composable +private fun TabScreen(page: Page, largeText: Boolean, onLargeText: (Boolean) -> Unit, content: @Composable () -> Unit) { + DdsScaffold( + topBar = { + DdsTopBar( + title = page.title, + leading = { CatalogMark(DdsSpace.s24.dp, Modifier.ddsSplashTarget(priority = 0)) }, + actions = { + DdsIconButton( + if (largeText) DdsIcons.Minus else DdsIcons.Plus, + contentDescription = if (largeText) "Smaller text" else "Larger text", + onClick = { onLargeText(!largeText) }, + ) + }, + ) + }, + bottomTabs = true, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { content() } + } +} + +/** A screen opened from a tab: the arrow back and its name; no bottom tabs. */ +@Composable +private fun InnerScreen(title: String, onBack: () -> Unit, content: @Composable () -> Unit) { + DdsScaffold(topBar = { DdsTopBar(title, onBack = onBack) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { content() } + } +} diff --git a/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt index 585106dd..dd44ec85 100644 --- a/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt +++ b/android/catalog/src/main/kotlin/kr/devslab/dds/catalog/Pages.kt @@ -82,6 +82,8 @@ import kr.devslab.dds.compose.component.DdsTextField import kr.devslab.dds.compose.component.DdsTone import kr.devslab.dds.compose.icons.DdsIcon import kr.devslab.dds.compose.icons.DdsIcons +import kr.devslab.dds.compose.layout.DdsBodyColumn +import kr.devslab.dds.compose.layout.ddsBodyLines import kr.devslab.dds.compose.motion.DdsBusyBar import kr.devslab.dds.compose.motion.DdsLoadingRows import kr.devslab.dds.compose.motion.DdsMotion @@ -104,18 +106,13 @@ import kr.devslab.dds.tokens.DdsScreen import kr.devslab.dds.tokens.DdsSpace import kr.devslab.dds.tokens.DdsTokensInfo -/** The scrolling body of a page: the part that arrives when the page is opened. */ +/** + * The scrolling body of a page: the part that arrives when the page is opened. A body column + * stands on the window's lines: the same start line on every page, beside the rail or not. + */ @Composable private fun PageBody(content: @Composable ColumnScope.() -> Unit) { - Column( - Modifier - .fillMaxSize() - .ddsScreenContent() - .verticalScroll(rememberScrollState()) - .padding(horizontal = DdsScreen.gutter.dp, vertical = DdsSpace.s16.dp), - verticalArrangement = Arrangement.spacedBy(DdsScreen.blockGap.dp), - content = content, - ) + DdsBodyColumn(content = content) } @Composable @@ -561,7 +558,7 @@ fun MotionPage(onOpen: (Int) -> Unit) { } // Drawn over the content, so work starting or ending never pushes the page down; // hidden while the pull indicator is already showing the same work. - DdsBusyBar(busy = busy && !pulling, modifier = Modifier.padding(horizontal = DdsScreen.gutter.dp)) + DdsBusyBar(busy = busy && !pulling, modifier = Modifier.ddsBodyLines()) } } diff --git a/android/dds-compose-testing/build.gradle.kts b/android/dds-compose-testing/build.gradle.kts new file mode 100644 index 00000000..5ce698d1 --- /dev/null +++ b/android/dds-compose-testing/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.maven.publish) +} + +// What an app's device tests use to check its own screens against the screen frame +// (androidTestImplementation). Published with the other two, at the same version. +// It measures the parts :dds-compose tags (DdsTestTags); it draws nothing itself. +android { + namespace = "kr.devslab.dds.compose.testing" + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) + } + explicitApi() +} + +dependencies { + api(project(":dds-compose")) + // Named here too, so this artifact's own POM carries the version of the test rule below. + api(platform(libs.compose.bom)) + api(libs.compose.ui.test.junit4) + // The test rule above brings Espresso 3.5.0 through the BOM, which does not run on Android 17. + // An app that adds this artifact gets 3.7.0 from it, as this repo's own device tests do. + api(libs.androidx.test.espresso.core) +} + +mavenPublishing { + coordinates(artifactId = "dds-compose-testing") + pom { + name.set("DDS Compose Testing") + } +} diff --git a/android/dds-compose-testing/src/main/kotlin/kr/devslab/dds/compose/testing/FrameCheck.kt b/android/dds-compose-testing/src/main/kotlin/kr/devslab/dds/compose/testing/FrameCheck.kt new file mode 100644 index 00000000..8eec3aa6 --- /dev/null +++ b/android/dds-compose-testing/src/main/kotlin/kr/devslab/dds/compose/testing/FrameCheck.kt @@ -0,0 +1,345 @@ +package kr.devslab.dds.compose.testing + +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.ViewRootForTest +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import java.util.Locale +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.roundToInt +import kr.devslab.dds.compose.layout.DdsTestTags +import kr.devslab.dds.compose.layout.DdsWindowWidth +import kr.devslab.dds.compose.layout.ddsWindowWidth +import kr.devslab.dds.tokens.DdsBorder +import kr.devslab.dds.tokens.DdsScreen + +/** + * What kind of screen is being looked at. The kind decides which menus the screen must have + * at each window width. + * + * | Kind | Rail | Fixed list | Bottom tabs | + * | --- | --- | --- | --- | + * | [TopLevel] | 600dp and wider | never | under 600dp | + * | [Inner] | 600dp and wider | never | never | + * | [Listed] | 600dp and wider | 840dp and wider | never | + * | [WholeWindow] | never | never | never | + */ +public enum class DdsScreenKind { + /** A screen a tab opens. */ + TopLevel, + + /** A screen opened from another one, with a back arrow. */ + Inner, + + /** A screen of an area that has a list: the list is fixed beside it on a wide window. */ + Listed, + + /** A screen that turns the menus off and takes the whole window (sign-in, a scanner, a form). */ + WholeWindow, +} + +/** + * What [DdsFrameCheck.look] measured on one screen. Lengths are in dp of the screen itself, from + * the left edge of the window. An app can write these to a file as evidence. + * + * @property name the name the test gave the screen + * @property kind the kind the test said the screen is + * @property window the width class of the window at the time + * @property windowWidthDp the width of the window + * @property bodyLeftDp where the body column (or the centered block) starts + * @property bodyWidthDp how wide the body column (or the centered block) is + * @property titleLeftDp where the title in the screen's top bar starts; null when the screen has + * no top bar, and for a centered block + * @property rail whether the navigation rail was on screen + * @property listPane whether the fixed list was on screen + * @property bottomTabs whether the bottom tabs were on screen + * @property centered true when the screen's one block is a `DdsCenteredColumn`, not a body column + */ +public class DdsScreenReading( + public val name: String, + public val kind: DdsScreenKind, + public val window: DdsWindowWidth, + public val windowWidthDp: Float, + public val bodyLeftDp: Float, + public val bodyWidthDp: Float, + public val titleLeftDp: Float?, + public val rail: Boolean, + public val listPane: Boolean, + public val bottomTabs: Boolean, + public val centered: Boolean, +) { + override fun toString(): String = + "DdsScreenReading(name=$name, kind=$kind, window=$window, windowWidthDp=$windowWidthDp, " + + "bodyLeftDp=$bodyLeftDp, bodyWidthDp=$bodyWidthDp, titleLeftDp=$titleLeftDp, rail=$rail, " + + "listPane=$listPane, bottomTabs=$bottomTabs, centered=$centered)" +} + +/** + * Checks an app's own screens against the DDS screen frame, in the app's device tests. + * + * Open a screen, then call [look] with a name for it and its kind. [look] fails, naming the + * screen, when the screen breaks a rule of the frame. After the last screen, [assertSameLines] + * fails when two screens of one kind do not start on the same line. + * + * ``` + * val check = DdsFrameCheck(composeRule) + * openOrders() + * check.look("orders", DdsScreenKind.TopLevel) + * openOrder(7) + * check.look("order detail", DdsScreenKind.Inner) + * check.assertSameLines() + * ``` + * + * It finds the parts by the tags the DDS parts carry ([DdsTestTags]), so it only sees screens + * built from them: `DdsAppFrame`, `DdsScaffold`, `DdsTopBar`, `DdsBodyColumn` (or `ddsBody()`), + * `DdsCenteredColumn`. Look at a screen when it is at rest, not while it is changing. + * + * @param rule the Compose test rule of the test + */ +public class DdsFrameCheck(private val rule: ComposeTestRule) { + private val seen = mutableListOf() + + /** Every screen that passed [look] so far, in order. */ + public val readings: List get() = seen.toList() + + /** + * Measures the screen showing now and checks it against the rules of the frame: + * + * 1. Exactly one body is on screen: one body column, or one centered block. + * 2. The menus are the ones the window width and [kind] call for (the table at [DdsScreenKind]). + * 3. A fixed list starts where the rail ends, and the body starts after the list ends. + * 4. The body is in the middle of the area the frame leaves: from the end of the fixed list, + * or of the rail, or from the window's edge, to the window's other edge, less any display + * cutout at the sides. + * 5. The title of the screen's top bar is inside the body column's left and right edges. The + * top bar of a fixed list is not the screen's and is left out. A screen with no top bar, + * or with a centered block, has no title to check. + * + * @param name what to call the screen in a failure and in the reading + * @param kind what kind of screen it is + * @return what was measured; it is also added to [readings] + * @throws AssertionError naming the screen, the rule and the measured numbers, when a rule is broken + */ + public fun look(name: String, kind: DdsScreenKind): DdsScreenReading { + rule.waitForIdle() + val bodies = shown(DdsTestTags.Body) + val blocks = shown(DdsTestTags.Centered) + if (bodies.size + blocks.size != 1) { + fail( + name, + "expected exactly one body on screen, found ${bodies.size} body column(s) and ${blocks.size} centered block(s). " + + "A screen has one body: DdsBodyColumn (or a column with ddsBody()), or one DdsCenteredColumn. " + + "Things beside the body that should stand on its lines use ddsBodyLines().", + ) + } + val centered = blocks.isNotEmpty() + val body = (bodies + blocks).single() + val density = body.layoutInfo.density.density + val rtl = body.layoutInfo.layoutDirection == LayoutDirection.Rtl + val window = windowOf(body) + val (insetLeft, insetRight) = sideInsets(body) + // Everything below is measured in pixels from the window's start edge (its right edge when right to left). + val startInset = max(0f, if (rtl) window.right - insetRight else insetLeft - window.left) + val endInset = max(0f, if (rtl) insetLeft - window.left else window.right - insetRight) + fun span(node: SemanticsNode): Span { + val left = node.positionInRoot.x + val right = left + node.size.width + return if (rtl) Span(window.right - right, window.right - left) else Span(left - window.left, right - window.left) + } + fun dp(px: Float): String = one(px / density) + fun Span.dp(): String = "${dp(start)}..${dp(end)}dp" + + val windowWidthDp = window.width / density + val width = ddsWindowWidth(windowWidthDp.dp) + val where = "$kind screen, $width window ${dp(window.width)}dp wide" + val rail = shown(DdsTestTags.Rail).firstOrNull() + val pane = shown(DdsTestTags.ListPane).firstOrNull() + val tabs = shown(DdsTestTags.BottomTabs).isNotEmpty() + + // The menus this window width and this kind of screen call for. + val wantRail = kind != DdsScreenKind.WholeWindow && width != DdsWindowWidth.Compact + val wantList = kind == DdsScreenKind.Listed && width == DdsWindowWidth.Expanded + val wantTabs = kind == DdsScreenKind.TopLevel && width == DdsWindowWidth.Compact + val wrong = buildList { + if (rail != null && !wantRail) add("the rail is on screen, and a $kind screen on a $width window has none") + if (rail == null && wantRail) add("the rail is missing: a $kind screen on a window 600dp or wider is beside the rail DdsAppFrame draws") + if (pane != null && !wantList) add("the fixed list is on screen, and a $kind screen on a $width window has none (pass list = null to DdsAppFrame)") + if (pane == null && wantList) add("the fixed list is missing: a Listed screen on a window 840dp or wider has its list beside it (DdsAppFrame's list)") + if (tabs && !wantTabs) add("the bottom tabs are on screen, and a $kind screen on a $width window has none (DdsScaffold(bottomTabs = true) is for top-level screens)") + if (!tabs && wantTabs) add("the bottom tabs are missing: a TopLevel screen on a window under 600dp draws them with DdsScaffold(bottomTabs = true) inside DdsAppFrame") + } + if (wrong.isNotEmpty()) fail(name, "the menus are not the ones for a $where: ${wrong.joinToString("; ")}.") + + val onePixel = 1f + val oneDp = density + val hairline = max(1f, (DdsBorder.hairline * density).roundToInt().toFloat()) + val bodySpan = span(body) + if (pane != null) { + val paneSpan = span(pane) + val expected = if (rail != null) span(rail).end + hairline else startInset + if (abs(paneSpan.start - expected) > oneDp) { + fail( + name, + "the fixed list does not start where the rail ends: the list is at ${paneSpan.dp()}, " + + (if (rail != null) "the rail at ${span(rail).dp()}" else "there is no rail") + + ", so the list should start at ${dp(expected)}dp ($where).", + ) + } + if (bodySpan.start < paneSpan.end - onePixel) { + fail( + name, + "the body starts before the fixed list ends: the body is at ${bodySpan.dp()}, the list at ${paneSpan.dp()} ($where). " + + "The screen belongs in DdsAppFrame's content, beside the list.", + ) + } + } + + val edge = pane ?: rail + val area = Span(if (edge != null) span(edge).end + hairline else startInset, window.width - endInset) + if (abs(bodySpan.middle - area.middle) > oneDp) { + val what = if (centered) "centered block" else "body" + fail( + name, + "the $what is not in the middle of the area the frame leaves: the $what is at ${bodySpan.dp()} (middle ${dp(bodySpan.middle)}), " + + "the area is ${area.dp()} (middle ${dp(area.middle)}) ($where). " + + "Use DdsBodyColumn, or center the column that carries ddsBody().", + ) + } + + var titleLeftDp: Float? = null + if (!centered) { + val paneSpan = pane?.let { span(it) } + val titles = shown(DdsTestTags.TopBarTitle).filter { title -> paneSpan == null || span(title).middle !in paneSpan } + val mine = titles.filter { span(it).middle in bodySpan } + if (titles.isNotEmpty() && mine.isEmpty()) { + fail( + name, + "the top bar's title is outside the body column: the title is at ${span(titles.first()).dp()}, the body at ${bodySpan.dp()} ($where). " + + "Give DdsTopBar's columnMax the same value as the body's max (both are ${DdsScreen.bodyMax}dp by default).", + ) + } + for (title in mine) { + val titleSpan = span(title) + if (titleSpan.start < bodySpan.start - onePixel || titleSpan.end > bodySpan.end + onePixel) { + fail( + name, + "the top bar's title is outside the body column: the title is at ${titleSpan.dp()}, the body at ${bodySpan.dp()} ($where). " + + "Give DdsTopBar's columnMax the same value as the body's max (both are ${DdsScreen.bodyMax}dp by default).", + ) + } + } + titleLeftDp = mine.firstOrNull()?.let { (it.positionInRoot.x - window.left) / density } + } + + val reading = DdsScreenReading( + name = name, + kind = kind, + window = width, + windowWidthDp = windowWidthDp, + bodyLeftDp = (body.positionInRoot.x - window.left) / density, + bodyWidthDp = body.size.width / density, + titleLeftDp = titleLeftDp, + rail = rail != null, + listPane = pane != null, + bottomTabs = tabs, + centered = centered, + ) + seen += reading + return reading + } + + /** + * Checks that the screens looked at so far stand on the same lines: two screens of the same + * kind, in a window of the same width, both beside a fixed list or both not, must start at the + * same place and be the same width, within 1dp. Body columns are compared with body columns, + * centered blocks with centered blocks. + * + * A screen that passes a `max` of its own to its body (a document page, a wide table) is on + * other lines on purpose: keep it out of the check that calls this, or use a second + * [DdsFrameCheck] for it. + * + * @throws AssertionError naming the two screens and their lines, when two differ + */ + public fun assertSameLines() { + val groups = seen.groupBy { Line(it.kind, it.listPane, it.centered, it.windowWidthDp.roundToInt()) } + for ((line, screens) in groups) { + val first = screens.first() + for (other in screens.drop(1)) { + if (abs(other.bodyLeftDp - first.bodyLeftDp) > 1f || abs(other.bodyWidthDp - first.bodyWidthDp) > 1f) { + throw AssertionError( + "Screens \"${first.name}\" and \"${other.name}\" are both ${line.kind} screens in a window ${line.windowWidthDp}dp wide, " + + "but their bodies are not on the same lines: \"${first.name}\" starts at ${one(first.bodyLeftDp)}dp and is " + + "${one(first.bodyWidthDp)}dp wide, \"${other.name}\" starts at ${one(other.bodyLeftDp)}dp and is " + + "${one(other.bodyWidthDp)}dp wide. Screens of one kind use the same body width.", + ) + } + } + } + } + + /** A length with one decimal, the same in every locale. */ + private fun one(value: Float): String = String.format(Locale.US, "%.1f", value) + + private fun fail(name: String, what: String): Nothing = throw AssertionError("Screen \"$name\": $what") + + /** The nodes carrying [tag] that are on screen now. */ + private fun shown(tag: String): List { + val all = rule.onAllNodesWithTag(tag, useUnmergedTree = true) + return all.fetchSemanticsNodes().indices.filter { all[it].isDisplayed() }.map { all[it].fetchSemanticsNode() } + } + + /** + * The window the screen is laid out in, in pixels of the root: the outermost layout that has the + * screen's own density. In an app that is the whole Compose view. In a test that forces a window + * size (which is done by changing the density) it is the forced window. + */ + private fun windowOf(node: SemanticsNode): Edges { + var info = node.layoutInfo + val density = info.density.density + while (true) { + val parent = info.parentInfo ?: break + if (parent.density.density != density) break + info = parent + } + val left = info.coordinates.positionInRoot().x + return Edges(left, left + info.width) + } + + /** + * Where the system bars and display cutouts leave room at the sides, as the left and right edges + * of the clear part in pixels of the root. What the Compose view does not reach is not counted. + */ + private fun sideInsets(node: SemanticsNode): Pair { + val view = (node.root as? ViewRootForTest)?.view + val insets = view?.let { ViewCompat.getRootWindowInsets(it) } + ?.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout()) + if (view == null || insets == null) return 0f to Float.MAX_VALUE + val at = IntArray(2) + view.getLocationInWindow(at) + val windowWidth = view.rootView.width + val left = max(0, insets.left - at[0]) + val right = max(0, insets.right - (windowWidth - at[0] - view.width)) + return left.toFloat() to (view.width - right).toFloat() + } + + /** Left and right in pixels of the root. */ + private class Edges(val left: Float, val right: Float) { + val width: Float get() = right - left + } + + /** A stretch across the window, in pixels from the window's start edge. */ + private class Span(val start: Float, val end: Float) { + val middle: Float get() = (start + end) / 2 + + operator fun contains(x: Float): Boolean = x in start..end + } + + private data class Line(val kind: DdsScreenKind, val listPane: Boolean, val centered: Boolean, val windowWidthDp: Int) +} diff --git a/android/dds-compose/build.gradle.kts b/android/dds-compose/build.gradle.kts index 9afbf2fd..78e0db15 100644 --- a/android/dds-compose/build.gradle.kts +++ b/android/dds-compose/build.gradle.kts @@ -49,6 +49,9 @@ dependencies { testImplementation(libs.junit) + // The check apps run on their own screens, run here on ours. Tests only: it is not a + // dependency of the published dds-compose. + androidTestImplementation(project(":dds-compose-testing")) androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.compose.ui.test.junit4.accessibility) androidTestImplementation(libs.androidx.test.runner) diff --git a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameCheckTest.kt b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameCheckTest.kt new file mode 100644 index 00000000..06a3d36a --- /dev/null +++ b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameCheckTest.kt @@ -0,0 +1,423 @@ +package kr.devslab.dds.compose + +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.ForcedSize +import androidx.compose.ui.test.LayoutDirection +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.component.DdsNavItem +import kr.devslab.dds.compose.component.DdsPaneItem +import kr.devslab.dds.compose.component.DdsPaneList +import kr.devslab.dds.compose.component.DdsScaffold +import kr.devslab.dds.compose.component.DdsTopBar +import kr.devslab.dds.compose.icons.DdsIcons +import kr.devslab.dds.compose.layout.DdsAppFrame +import kr.devslab.dds.compose.layout.DdsBodyColumn +import kr.devslab.dds.compose.layout.DdsCenteredColumn +import kr.devslab.dds.compose.layout.DdsTestTags +import kr.devslab.dds.compose.layout.DdsWindowWidth +import kr.devslab.dds.compose.layout.ddsBody +import kr.devslab.dds.compose.testing.DdsFrameCheck +import kr.devslab.dds.compose.testing.DdsScreenKind +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsBar +import kr.devslab.dds.tokens.DdsBorder +import kr.devslab.dds.tokens.DdsScreen +import kr.devslab.dds.tokens.DdsSpace +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * The check an app runs on its own screens (`dds-compose-testing`): a frame that keeps the rules + * passes, and each rule on its own stops a screen that breaks it, naming the screen. + */ +class FrameCheckTest { + @get:Rule + val rule = createComposeRule() + + private val four = listOf("홈", "거래처", "주문", "설정").map { DdsNavItem(it, DdsIcons.Doc) } + + private val phone = DpSize(475.dp, 751.dp) + private val medium = DpSize(704.dp, 933.dp) + private val wide = DpSize(933.dp, 704.dp) + + private val check by lazy { DdsFrameCheck(rule) } + + /** [content] under the theme in a window forced to [size]; [rtl] lays it out right to left. */ + private fun shown(size: () -> DpSize, rtl: Boolean = false, content: @Composable () -> Unit) { + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size())) { + DeviceConfigurationOverride( + DeviceConfigurationOverride.LayoutDirection(if (rtl) LayoutDirection.Rtl else LayoutDirection.Ltr), + ) { + DdsTheme { content() } + } + } + } + } + + /** What a pinned list holds in an app: its own top bar over its rows. */ + @Composable + private fun PinnedList() { + DdsScaffold(topBar = { DdsTopBar("목록 제목") }) { padding -> + DdsPaneList(Modifier.padding(padding)) { + DdsPaneItem("매출 현황", selected = true, onClick = {}) + DdsPaneItem("미수금", selected = false, onClick = {}) + } + } + } + + /** A screen of [kind] built the way the guide says: the frame, the scaffold, the top bar, the body column. */ + @Composable + private fun GoodScreen(kind: DdsScreenKind, title: String = "화면 제목") { + DdsAppFrame( + navItems = four, + selectedIndex = 0, + onSelect = {}, + navigation = kind != DdsScreenKind.WholeWindow, + list = if (kind == DdsScreenKind.Listed) ({ PinnedList() }) else null, + ) { + DdsScaffold( + topBar = { DdsTopBar(title, onBack = if (kind == DdsScreenKind.TopLevel) null else ({})) }, + bottomTabs = kind == DdsScreenKind.TopLevel, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { DdsBodyColumn { Text("본문") } } + } + } + } + + private fun bounds(tag: String) = rule.onNodeWithTag(tag, useUnmergedTree = true).getUnclippedBoundsInRoot() + + private fun assertNear(message: String, expected: Dp, actual: Float) = assertEquals(message, expected.value, actual, 1f) + + /** The message of the failure [look] ends in; fails when the check lets the screen through. */ + private fun refusal(look: () -> Unit): String = assertThrows(AssertionError::class.java) { look() }.message.orEmpty() + + // ---- a frame that keeps the rules ---- + + @Test + fun aFrameThatKeepsTheRulesPasses() { + var size by mutableStateOf(phone) + var kind by mutableStateOf(DdsScreenKind.TopLevel) + shown({ size }) { GoodScreen(kind) } + for (window in listOf(phone, medium, wide)) { + for (screen in DdsScreenKind.entries) { + size = window + kind = screen + rule.waitForIdle() + val name = "$screen at ${window.width}" + val reading = check.look(name, screen) + + val width = when (window) { + phone -> DdsWindowWidth.Compact + medium -> DdsWindowWidth.Medium + else -> DdsWindowWidth.Expanded + } + assertEquals(name, name, reading.name) + assertEquals(name, screen, reading.kind) + assertEquals("$name: the window's width class", width, reading.window) + assertNear("$name: the window's width", window.width, reading.windowWidthDp) + val menus = screen != DdsScreenKind.WholeWindow + assertEquals("$name: rail", menus && width != DdsWindowWidth.Compact, reading.rail) + assertEquals("$name: list", screen == DdsScreenKind.Listed && width == DdsWindowWidth.Expanded, reading.listPane) + assertEquals("$name: bottom tabs", screen == DdsScreenKind.TopLevel && width == DdsWindowWidth.Compact, reading.bottomTabs) + assertFalse("$name: a body column, not a centered block", reading.centered) + + // What the reading says is what the body measures on screen. + val body = bounds(DdsTestTags.Body) + assertNear("$name: the body's left", body.left, reading.bodyLeftDp) + assertNear("$name: the body's width", body.right - body.left, reading.bodyWidthDp) + val areaLeft = when { + reading.listPane -> (DdsBar.rail + DdsBorder.hairline + DdsScreen.listPane + DdsBorder.hairline).dp + reading.rail -> (DdsBar.rail + DdsBorder.hairline).dp + else -> 0.dp + } + val area = window.width - areaLeft + val expected = minOf(DdsScreen.bodyMax.dp, area) + assertNear("$name: the body is as wide as the area allows, up to its limit", expected, reading.bodyWidthDp) + assertNear("$name: the body is in the middle of the area", areaLeft + (area - expected) / 2, reading.bodyLeftDp) + + // The title starts on the body's text line, or 40dp after it behind a back arrow. + val gutter = if (width == DdsWindowWidth.Compact) DdsScreen.gutter.dp else DdsScreen.gutterWide.dp + val afterArrow = if (screen == DdsScreenKind.TopLevel) 0.dp else DdsSpace.s40.dp + assertNotNull("$name: the title was read", reading.titleLeftDp) + assertNear("$name: where the title starts", body.left + gutter + afterArrow, reading.titleLeftDp!!) + } + } + assertEquals("every look is kept", 12, check.readings.size) + // Screens of different kinds, and the same kind in windows of different widths, are on different lines by design. + check.assertSameLines() + } + + @Test + fun inRightToLeftAFrameThatKeepsTheRulesPasses() { + var kind by mutableStateOf(DdsScreenKind.TopLevel) + shown({ wide }, rtl = true) { GoodScreen(kind) } + for (screen in DdsScreenKind.entries) { + kind = screen + rule.waitForIdle() + val reading = check.look("$screen right to left", screen) + val body = bounds(DdsTestTags.Body) + assertNear("$screen: the body's left", body.left, reading.bodyLeftDp) + assertEquals("$screen: list", screen == DdsScreenKind.Listed, reading.listPane) + } + // The rail is on the right, so a screen beside it starts at the window's left edge and stops short of the rail. + val inner = check.readings.single { it.kind == DdsScreenKind.Inner } + assertTrue("the body ends before the rail on the right: $inner", inner.bodyLeftDp + inner.bodyWidthDp < wide.width.value - DdsBar.rail) + } + + @Test + fun aScreenWhoseOneBlockIsCenteredPasses() { + var size by mutableStateOf(wide) + shown({ size }) { + DdsAppFrame(four, 0, {}, navigation = false) { + DdsCenteredColumn { Text("한 가지 일", Modifier.fillMaxWidth()) } + } + } + val gate = check.look("gate", DdsScreenKind.WholeWindow) + assertTrue("the gate is read as a centered block", gate.centered) + assertNear("the block's width", DdsScreen.centeredMax.dp, gate.bodyWidthDp) + assertNear("the block is in the middle of the window", (wide.width - DdsScreen.centeredMax.dp) / 2, gate.bodyLeftDp) + assertNull("a gate has no top bar", gate.titleLeftDp) + size = phone + rule.waitForIdle() + check.look("gate on a phone", DdsScreenKind.WholeWindow) + check.assertSameLines() + } + + // ---- one screen per rule that breaks it ---- + + @Test + fun twoBodiesOnOneScreenFail() { + shown({ wide }) { + DdsAppFrame(four, 0, {}) { + DdsScaffold(topBar = {}) { padding -> + Row(Modifier.fillMaxSize().padding(padding)) { + Box(Modifier.weight(1f)) { DdsBodyColumn { Text("왼쪽") } } + Box(Modifier.weight(1f)) { DdsBodyColumn { Text("오른쪽") } } + } + } + } + } + val message = refusal { check.look("split screen", DdsScreenKind.Inner) } + assertTrue(message, "split screen" in message) + assertTrue(message, "exactly one body" in message && "found 2" in message) + assertTrue("a refused screen leaves no reading", check.readings.isEmpty()) + } + + @Test + fun aScreenWithNoBodyFails() { + shown({ wide }) { + DdsAppFrame(four, 0, {}) { + DdsScaffold(topBar = { DdsTopBar("본문 없음") }) { padding -> Text("그냥 글자", Modifier.padding(padding)) } + } + } + val message = refusal { check.look("bare screen", DdsScreenKind.Inner) } + assertTrue(message, "bare screen" in message) + assertTrue(message, "exactly one body" in message && "found 0" in message) + } + + @Test + fun aBodyAndACenteredBlockTogetherFail() { + shown({ wide }) { + DdsAppFrame(four, 0, {}, navigation = false) { + Row(Modifier.fillMaxSize()) { + Box(Modifier.weight(1f)) { DdsBodyColumn { Text("본문") } } + Box(Modifier.weight(1f)) { DdsCenteredColumn { Text("한 가지 일") } } + } + } + } + val message = refusal { check.look("two things", DdsScreenKind.WholeWindow) } + assertTrue(message, "two things" in message) + assertTrue(message, "exactly one body" in message) + } + + /** A top bar narrower than the body stays inside it; one not kept to the body's width does not. */ + @Test + fun aNarrowerBarIsAllowedButATitleOutsideTheBodyIsNot() { + var barMax by mutableStateOf(400.dp) + shown({ wide }) { + DdsAppFrame(four, 0, {}) { + DdsScaffold(topBar = { DdsTopBar("화면 제목", columnMax = barMax) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { DdsBodyColumn { Text("본문") } } + } + } + } + val narrow = check.look("narrow bar", DdsScreenKind.Inner) + val body = bounds(DdsTestTags.Body) + assertNear("a 400dp bar over a 680dp body starts 140dp further in", body.left + 140.dp + DdsScreen.gutterWide.dp, narrow.titleLeftDp!!) + + barMax = 2000.dp // not kept to the body: the title starts at the area's edge + rule.waitForIdle() + val message = refusal { check.look("full-width bar", DdsScreenKind.Inner) } + assertTrue(message, "full-width bar" in message) + assertTrue(message, "title" in message && "outside the body" in message) + } + + @Test + fun aBodyAtTheStartInsteadOfTheMiddleFails() { + shown({ wide }) { + DdsAppFrame(four, 0, {}) { + DdsScaffold(topBar = {}) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopStart) { + Column(Modifier.ddsBody().fillMaxWidth().fillMaxHeight()) { Text("본문") } + } + } + } + } + val message = refusal { check.look("start-aligned", DdsScreenKind.Inner) } + assertTrue(message, "start-aligned" in message) + assertTrue(message, "not in the middle" in message) + } + + @Test + fun aRailOnAWholeWindowScreenFails() { + shown({ wide }) { GoodScreen(DdsScreenKind.Inner) } + val message = refusal { check.look("scanner", DdsScreenKind.WholeWindow) } + assertTrue(message, "scanner" in message) + assertTrue(message, "the rail is on screen" in message) + } + + @Test + fun bottomTabsOnAnInnerScreenFail() { + shown({ phone }) { GoodScreen(DdsScreenKind.TopLevel) } + val message = refusal { check.look("detail", DdsScreenKind.Inner) } + assertTrue(message, "detail" in message) + assertTrue(message, "the bottom tabs are on screen" in message) + } + + @Test + fun aTopLevelScreenWithoutBottomTabsOnAPhoneFails() { + shown({ phone }) { GoodScreen(DdsScreenKind.Inner) } + val message = refusal { check.look("home", DdsScreenKind.TopLevel) } + assertTrue(message, "home" in message) + assertTrue(message, "the bottom tabs are missing" in message) + } + + @Test + fun aScreenWithoutTheRailOnAMediumWindowFails() { + shown({ medium }) { GoodScreen(DdsScreenKind.WholeWindow) } + val message = refusal { check.look("detail", DdsScreenKind.Inner) } + assertTrue(message, "detail" in message) + assertTrue(message, "the rail is missing" in message) + } + + @Test + fun aListedScreenWithoutItsListOnAWideWindowFails() { + shown({ wide }) { GoodScreen(DdsScreenKind.Inner) } + val message = refusal { check.look("sales", DdsScreenKind.Listed) } + assertTrue(message, "sales" in message) + assertTrue(message, "the fixed list is missing" in message) + } + + @Test + fun aFixedListBesideAnInnerScreenFails() { + shown({ wide }) { GoodScreen(DdsScreenKind.Listed) } + val message = refusal { check.look("detail", DdsScreenKind.Inner) } + assertTrue(message, "detail" in message) + assertTrue(message, "the fixed list is on screen" in message) + } + + /** A frame put together by hand, so the list can be put where the real frame never puts it. */ + @Composable + private fun HandMadeFrame(gapAfterRail: Dp, bodyOverTheList: Boolean) { + Row(Modifier.fillMaxSize()) { + Box(Modifier.width(DdsBar.rail.dp).fillMaxHeight().testTag(DdsTestTags.Rail)) { Text("레일") } + Spacer(Modifier.width(gapAfterRail)) + if (bodyOverTheList) { + Box(Modifier.weight(1f).fillMaxHeight()) { + DdsBodyColumn { Text("본문") } + Box(Modifier.width(DdsScreen.listPane.dp).fillMaxHeight().testTag(DdsTestTags.ListPane)) { Text("목록") } + } + } else { + Box(Modifier.width(DdsScreen.listPane.dp).fillMaxHeight().testTag(DdsTestTags.ListPane)) { Text("목록") } + Box(Modifier.weight(1f).fillMaxHeight()) { DdsBodyColumn { Text("본문") } } + } + } + } + + @Test + fun aFixedListThatDoesNotStartAtTheRailFails() { + shown({ wide }) { HandMadeFrame(gapAfterRail = 40.dp, bodyOverTheList = false) } + val message = refusal { check.look("gap", DdsScreenKind.Listed) } + assertTrue(message, "gap" in message) + assertTrue(message, "does not start where the rail ends" in message) + } + + @Test + fun aBodyThatStartsUnderTheFixedListFails() { + shown({ wide }) { HandMadeFrame(gapAfterRail = DdsBorder.hairline.dp, bodyOverTheList = true) } + val message = refusal { check.look("under the list", DdsScreenKind.Listed) } + assertTrue(message, "under the list" in message) + assertTrue(message, "starts before the fixed list ends" in message) + } + + // ---- several screens: the same lines ---- + + @Test + fun twoScreensOfAKindOnDifferentLinesFail() { + var bodyMax by mutableStateOf(DdsScreen.bodyMax.dp) + shown({ wide }) { + DdsAppFrame(four, 0, {}) { + DdsScaffold(topBar = { DdsTopBar("화면 제목", onBack = {}, columnMax = bodyMax) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { DdsBodyColumn(max = bodyMax) { Text("본문") } } + } + } + } + check.look("orders", DdsScreenKind.Inner) + check.assertSameLines() // one screen is on its own lines + bodyMax = 600.dp // centered and under its bar, so the screen passes alone + rule.waitForIdle() + check.look("order detail", DdsScreenKind.Inner) + val message = refusal { check.assertSameLines() } + assertTrue(message, "orders" in message && "order detail" in message) + assertTrue(message, "not on the same lines" in message) + // The measured numbers are in the message: the two widths, each within a pixel of what was asked for. + val widths = Regex("is (\\d+\\.\\d)dp wide").findAll(message).map { it.groupValues[1].toFloat() }.toList() + assertEquals(message, 2, widths.size) + assertEquals(message, DdsScreen.bodyMax.toFloat(), widths[0], 1f) + assertEquals(message, 600f, widths[1], 1f) + } + + // ---- the fixed list has a top bar of its own ---- + + @Test + fun withAFixedListTheScreensTitleIsRead() { + shown({ wide }) { GoodScreen(DdsScreenKind.Listed, title = "매출 현황 화면") } + val reading = check.look("sales", DdsScreenKind.Listed) + val listTitle = rule.onNodeWithText("목록 제목", useUnmergedTree = true).getUnclippedBoundsInRoot() + val screenTitle = rule.onNodeWithText("매출 현황 화면", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertTrue("two top bars are on screen, the list's first: $listTitle, $screenTitle", listTitle.left < screenTitle.left) + assertNear("the title read is the screen's", screenTitle.left, reading.titleLeftDp!!) + assertTrue("and not the list's", reading.titleLeftDp!! > listTitle.right.value) + } +} diff --git a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameOnDeviceTest.kt b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameOnDeviceTest.kt new file mode 100644 index 00000000..3a0c4598 --- /dev/null +++ b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameOnDeviceTest.kt @@ -0,0 +1,1509 @@ +package kr.devslab.dds.compose + +import androidx.compose.foundation.clickable +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Alignment +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.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.FontScale +import androidx.compose.ui.test.ForcedSize +import androidx.compose.ui.test.LayoutDirection +import androidx.compose.ui.test.WindowInsets +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.component.DdsBottomBar +import kr.devslab.dds.compose.component.DdsNavItem +import kr.devslab.dds.compose.component.DdsNavRail +import kr.devslab.dds.compose.component.DdsScaffold +import kr.devslab.dds.compose.icons.DdsIcons +import kr.devslab.dds.compose.layout.DdsAppFrame +import kr.devslab.dds.compose.layout.DdsBodyColumn +import kr.devslab.dds.compose.layout.ddsBodyLines +import kr.devslab.dds.compose.motion.DdsMove +import kr.devslab.dds.compose.motion.DdsScreenHost +import kr.devslab.dds.compose.motion.isReducedMotion +import androidx.test.platform.app.InstrumentationRegistry +import kr.devslab.dds.compose.layout.DdsFrame +import kr.devslab.dds.compose.layout.DdsTestTags +import kr.devslab.dds.compose.layout.DdsWindowWidth +import kr.devslab.dds.compose.layout.ddsFrame +import kr.devslab.dds.compose.layout.ddsWindowWidth +import kr.devslab.dds.compose.layout.gutter +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsBar +import kr.devslab.dds.tokens.DdsBorder +import kr.devslab.dds.tokens.DdsScreen +import kr.devslab.dds.tokens.DdsIcon +import kr.devslab.dds.tokens.DdsSpace +import kr.devslab.dds.tokens.DdsTouch +import kr.devslab.dds.compose.component.DdsIconButton +import kr.devslab.dds.compose.component.DdsTopBar +import androidx.compose.foundation.layout.size +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.geometry.Offset +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.test.click +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.unit.Density +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.hasScrollAction +import androidx.compose.ui.test.performSemanticsAction +import kr.devslab.dds.compose.component.DdsPaneItem +import kr.devslab.dds.compose.component.DdsPaneList +import kr.devslab.dds.compose.layout.DdsCenteredColumn +import kr.devslab.dds.compose.layout.DdsCenteredTitle +import kr.devslab.dds.tokens.DdsControl +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.SemanticsMatcher +import kr.devslab.dds.compose.layout.ddsBody +import kr.devslab.dds.compose.testing.DdsFrameCheck +import kr.devslab.dds.compose.testing.DdsScreenKind +import kr.devslab.dds.compose.theme.DdsBrand +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.width +import androidx.compose.ui.text.font.FontWeight +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeFalse +import org.junit.Rule +import org.junit.Test + +/** + * The parts of the screen frame measured on a device: the navigation at large text, and + * (in later tests) the frame's size classes and body column. + */ +class FrameOnDeviceTest { + @get:Rule + val rule = createComposeRule() + + private val long = "거래처 매출 관리" + private val short = "거래처 관리" + + private fun items(vararg labels: String): List = labels.map { DdsNavItem(it, DdsIcons.Doc) } + + /** [content] under the theme at font scale [scale]. */ + private fun shown(scale: Float = 1f, content: @Composable () -> Unit) { + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(scale)) { + DdsTheme { content() } + } + } + } + + /** The text layout the node reports: how many lines it drew and whether it ran out of room. */ + private fun layoutOf(node: SemanticsNodeInteraction): TextLayoutResult { + val results = mutableListOf() + val semantics: SemanticsNode = node.fetchSemanticsNode() + semantics.config[SemanticsActions.GetTextLayoutResult].action!!.invoke(results) + return results.single() + } + + /** The label [text] is on two lines and inside the part tagged [barTag]; returns its layout. */ + private fun assertLabelIsTwoLinesInside(text: String, barTag: String): TextLayoutResult { + val label = rule.onNodeWithText(text, useUnmergedTree = true) + val layout = layoutOf(label) + assertEquals("the label's lines", 2, layout.lineCount) + val bar = rule.onNodeWithTag(barTag).getUnclippedBoundsInRoot() + val bounds = label.getUnclippedBoundsInRoot() + assertTrue("label $bounds is inside the bar $bar", bounds.top >= bar.top && bounds.bottom <= bar.bottom) + assertTrue("label $bounds is inside the bar $bar", bounds.left >= bar.left && bounds.right <= bar.right) + return layout + } + + @Test + fun aLongLabelIsShownOnTwoLinesNotCutInTheBottomTabs() { + // A folded phone's window, so each tab's share is narrow whatever the device's own window is. + shown(scale = 2f) { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(phone)) { + DdsBottomBar(items(long, "홈", "설정", "더보기"), 0, {}, Modifier) + } + } + val layout = assertLabelIsTwoLinesInside(long, DdsTestTags.BottomTabs) + assertFalse("the label ran out of room", layout.hasVisualOverflow) + } + + /** The rail is 80dp wide, so a label that fits two lines there is a shorter one. */ + @Test + fun aLabelIsShownOnTwoLinesNotCutInTheRail() { + shown(scale = 2f) { Box(Modifier.height(600.dp)) { DdsNavRail(items(short, "홈"), 0, {}) } } + val layout = assertLabelIsTwoLinesInside(short, DdsTestTags.Rail) + assertFalse("the label ran out of room", layout.hasVisualOverflow) + } + + /** What the 80dp rail does with a label that needs three lines: two, and an ellipsis, inside the rail. */ + @Test + fun aLabelTooLongForTheRailEndsInAnEllipsisAfterTwoLines() { + shown(scale = 2f) { Box(Modifier.height(600.dp)) { DdsNavRail(items(long, "홈"), 0, {}) } } + val layout = assertLabelIsTwoLinesInside(long, DdsTestTags.Rail) + assertTrue("the second line ends in an ellipsis", layout.isLineEllipsized(1)) + } + + @Test + fun theRailScrollsWhenItsItemsDoNotFit() { + val labels = (1..6).map { "항목 $it" }.toTypedArray() + shown { Box(Modifier.height(200.dp)) { DdsNavRail(items(*labels), 0, {}) } } + rule.onNodeWithText("항목 6").performScrollTo().assertIsDisplayed() + } + + @Test + fun theRailIsItsTokenWide() { + shown(scale = 2f) { Box(Modifier.height(600.dp)) { DdsNavRail(items(long, "홈"), 0, {}) } } + val bounds = rule.onNodeWithTag(DdsTestTags.Rail).getUnclippedBoundsInRoot() + assertEquals(DdsBar.rail.dp, bounds.right - bounds.left) + } + + // ---- the frame: what is drawn at each width ---- + + private val four = items("홈", "거래처", "주문", "설정") + + /** What a screen inside the frame sees from [ddsFrame]. */ + private var seen: DdsFrame? = null + + /** + * The frame with four destinations around one screen under a forced window [size]. The screen is a + * [DdsScaffold] drawing the bottom tabs when [topLevel]; its body is the box tagged `screen-body`. + * [override] wraps the frame in another device override (layout direction, insets). + */ + private fun frame( + size: DpSize, + navigation: Boolean = true, + list: Boolean = false, + topLevel: Boolean = true, + destinations: List = four, + override: @Composable (@Composable () -> Unit) -> Unit = { it() }, + body: @Composable (PaddingValues) -> Unit = { padding -> + seen = ddsFrame() + Box(Modifier.fillMaxSize().padding(padding).testTag("screen-body")) { Text("화면") } + }, + ) { + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size)) { + override { + DdsTheme { FrameUnderTest(navigation, list, topLevel, destinations, body) } + } + } + } + } + + @Composable + private fun FrameUnderTest( + navigation: Boolean, + list: Boolean, + topLevel: Boolean, + destinations: List, + body: @Composable (PaddingValues) -> Unit, + ) { + DdsAppFrame( + navItems = destinations, + selectedIndex = 0, + onSelect = {}, + modifier = Modifier.testTag("frame"), + navigation = navigation, + list = if (list) ({ Box(Modifier.fillMaxSize().testTag("list-body")) { Text("목록") } }) else null, + ) { + DdsScaffold(topBar = {}, bottomTabs = topLevel, content = body) + } + } + + private fun bounds(tag: String) = rule.onNodeWithTag(tag).getUnclippedBoundsInRoot() + + private fun assertNear(message: String, expected: Dp, actual: Dp) = + assertEquals(message, expected.value, actual.value, 0.6f) + + @Test + fun aNarrowWindowHasBottomTabsOnATopLevelScreenOnly() { + frame(DpSize(475.dp, 751.dp), list = true, topLevel = true) + rule.onNodeWithTag(DdsTestTags.BottomTabs).assertExists() + rule.onNodeWithTag(DdsTestTags.Rail).assertDoesNotExist() + rule.onNodeWithTag(DdsTestTags.ListPane).assertDoesNotExist() + } + + @Test + fun aNarrowWindowHasNoTabsOnADeeperScreen() { + frame(DpSize(475.dp, 751.dp), topLevel = false) + rule.onNodeWithTag(DdsTestTags.BottomTabs).assertDoesNotExist() + rule.onNodeWithTag(DdsTestTags.Rail).assertDoesNotExist() + } + + @Test + fun aMediumWindowHasTheRailAndNoList() { + frame(DpSize(704.dp, 933.dp), list = true) + rule.onNodeWithTag(DdsTestTags.Rail).assertExists() + rule.onNodeWithTag(DdsTestTags.ListPane).assertDoesNotExist() + rule.onNodeWithTag(DdsTestTags.BottomTabs).assertDoesNotExist() + } + + @Test + fun aWideWindowHasTheRailAndTheListBesideIt() { + frame(DpSize(933.dp, 704.dp), list = true) + val rail = bounds(DdsTestTags.Rail) + val list = bounds(DdsTestTags.ListPane) + val body = bounds("screen-body") + assertNear("rail starts at the window edge", 0.dp, rail.left) + assertNear("rail width", DdsBar.rail.dp, rail.right - rail.left) + assertNear("the list starts where the rail's row ends", rail.right + DdsBorder.hairline.dp, list.left) + assertNear("list width", DdsScreen.listPane.dp, list.right - list.left) + assertNear("a hairline follows the list, then the content", list.right + DdsBorder.hairline.dp, body.left) + } + + private fun assertNothingIsDrawnWithNavigationOff(size: DpSize) { + frame(size, navigation = false, list = true, topLevel = true) + rule.onNodeWithTag(DdsTestTags.Rail).assertDoesNotExist() + rule.onNodeWithTag(DdsTestTags.ListPane).assertDoesNotExist() + rule.onNodeWithTag(DdsTestTags.BottomTabs).assertDoesNotExist() + rule.onNodeWithTag("screen-body").assertExists() + } + + @Test + fun withNoDestinationsNoTabsOrRailAreDrawn() { + frame(DpSize(475.dp, 751.dp), destinations = emptyList(), topLevel = true) + rule.onNodeWithTag(DdsTestTags.BottomTabs).assertDoesNotExist() + rule.onNodeWithTag("screen-body").assertExists() + } + + @Test + fun withNoDestinationsNoRailIsDrawnWhenWide() { + frame(DpSize(933.dp, 704.dp), destinations = emptyList(), topLevel = true) + rule.onNodeWithTag(DdsTestTags.Rail).assertDoesNotExist() + rule.onNodeWithTag("screen-body").assertExists() + } + + @Test + fun withNavigationOffNothingIsDrawnWhenNarrow() = assertNothingIsDrawnWithNavigationOff(DpSize(475.dp, 751.dp)) + + @Test + fun withNavigationOffNothingIsDrawnWhenMedium() = assertNothingIsDrawnWithNavigationOff(DpSize(704.dp, 933.dp)) + + @Test + fun withNavigationOffNothingIsDrawnWhenWide() = assertNothingIsDrawnWithNavigationOff(DpSize(933.dp, 704.dp)) + + @Test + fun aScreenReadsWhatTheFrameDrew() { + frame(DpSize(933.dp, 704.dp), list = true) + assertEquals(DdsFrame(DdsWindowWidth.Expanded, rail = true, listPinned = true), seen) + } + + @Test + fun outsideAFrameAScreenReadsOnlyTheWindowWidth() { + var outside: DdsFrame? = null + var expectedWidth: DdsWindowWidth? = null + rule.setContent { + DdsTheme { + outside = ddsFrame() + val density = LocalDensity.current + expectedWidth = ddsWindowWidth(with(density) { LocalWindowInfo.current.containerSize.width.toDp() }) + } + } + assertEquals(DdsFrame(width = expectedWidth!!), outside) + } + + @Test + fun whatAScreenHoldsSurvivesTheWindowChangingWidth() { + var size by mutableStateOf(DpSize(475.dp, 751.dp)) + var scrollIndex = -1 + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size)) { + DdsTheme { + DdsAppFrame(four, 0, {}, list = { Text("목록") }) { + DdsScaffold(topBar = {}, bottomTabs = true) { padding -> + var count by rememberSaveable { mutableIntStateOf(0) } + val state = rememberLazyListState() + scrollIndex = state.firstVisibleItemIndex + Column(Modifier.padding(padding)) { + Text("count $count", Modifier.testTag("count").clickable { count++ }) + LazyColumn(Modifier.testTag("lazy"), state = state) { + items(60) { Text("줄 $it", Modifier.height(40.dp)) } + } + } + } + } + } + } + } + rule.onNodeWithTag("count").performClick() + rule.onNodeWithTag("count").performClick() + rule.onNodeWithTag("lazy").performScrollToIndex(20) + rule.waitForIdle() + assertEquals(20, scrollIndex) + rule.onNodeWithTag(DdsTestTags.Rail).assertDoesNotExist() + size = DpSize(933.dp, 704.dp) + rule.waitForIdle() + rule.onNodeWithTag(DdsTestTags.Rail).assertExists() + rule.onNodeWithTag(DdsTestTags.ListPane).assertExists() + rule.onNodeWithText("count 2").assertExists() + assertEquals("the scroll position", 20, scrollIndex) + size = DpSize(475.dp, 751.dp) + rule.waitForIdle() + rule.onNodeWithText("count 2").assertExists() + assertEquals("the scroll position after coming back", 20, scrollIndex) + } + + @Test + fun inRightToLeftTheRailIsOnTheRight() { + frame( + DpSize(933.dp, 704.dp), + list = true, + override = { content -> + DeviceConfigurationOverride(DeviceConfigurationOverride.LayoutDirection(LayoutDirection.Rtl)) { content() } + }, + ) + val root = bounds("frame") + val rail = bounds(DdsTestTags.Rail) + val list = bounds(DdsTestTags.ListPane) + assertNear("rail ends at the right edge", root.right, rail.right) + assertTrue("the list is left of the rail", list.right <= rail.left) + assertTrue("the body is left of the list", bounds("screen-body").right <= list.left) + } + + private fun withCutout(left: Dp = 0.dp, right: Dp = 0.dp): @Composable (@Composable () -> Unit) -> Unit = { content -> + val density = LocalDensity.current + val cutout = with(density) { Insets.of(left.roundToPx(), 0, right.roundToPx(), 0) } + val insets = WindowInsetsCompat.Builder().setInsets(WindowInsetsCompat.Type.displayCutout(), cutout).build() + DeviceConfigurationOverride(DeviceConfigurationOverride.WindowInsets(insets)) { content() } + } + + private val cutout = 40.dp + + /** The label is drawn whole: on its one line, with no ellipsis. */ + private fun assertLabelIsWhole(label: SemanticsNodeInteraction) { + val layout = layoutOf(label) + assertEquals("the label's lines", 1, layout.lineCount) + assertFalse("the label ends in an ellipsis", layout.isLineEllipsized(0)) + } + + @Test + fun aCutoutAtTheStartLeavesTheRailItsFullWidthAfterTheCutout() { + frame(DpSize(933.dp, 704.dp), list = true, override = withCutout(left = cutout)) + val rail = bounds(DdsTestTags.Rail) + val list = bounds(DdsTestTags.ListPane) + assertNear("the rail starts after the cutout", cutout, rail.left) + assertNear("the rail keeps its width", DdsBar.rail.dp, rail.right - rail.left) + assertNear("the list follows the rail", rail.right + DdsBorder.hairline.dp, list.left) + assertNear("the content follows the list, the cutout is not taken twice", list.right + DdsBorder.hairline.dp, bounds("screen-body").left) + val label = rule.onNodeWithText("거래처", useUnmergedTree = true) + assertLabelIsWhole(label) + val box = label.getUnclippedBoundsInRoot() + assertTrue("the label $box is inside the rail $rail", box.left >= rail.left && box.right <= rail.right) + } + + @Test + fun withoutACutoutTheRailLabelIsNotCutEither() { + frame(DpSize(933.dp, 704.dp), list = true) + assertLabelIsWhole(rule.onNodeWithText("거래처", useUnmergedTree = true)) + } + + @Test + fun aCutoutAtTheEndStopsTheContentAtTheCutout() { + frame(DpSize(933.dp, 704.dp), list = true, override = withCutout(right = cutout)) + assertNear("the content ends at the cutout", bounds("frame").right - cutout, bounds("screen-body").right) + assertNear("the rail is not moved", 0.dp, bounds(DdsTestTags.Rail).left) + } + + @Test + fun withNeitherRailNorListTheContentClearsBothCutouts() { + frame(DpSize(933.dp, 704.dp), navigation = false, topLevel = true, override = withCutout(left = cutout, right = cutout)) + val body = bounds("screen-body") + assertNear("starts at the start cutout", cutout, body.left) + assertNear("ends at the end cutout", bounds("frame").right - cutout, body.right) + } + + @Test + fun withTheListAndNoRailTheListClearsTheStartCutout() { + frame(DpSize(933.dp, 704.dp), list = true, destinations = emptyList(), override = withCutout(left = cutout, right = cutout)) + val list = bounds(DdsTestTags.ListPane) + assertNear("the list starts at the start cutout", cutout, list.left) + assertNear("the content ends at the end cutout", bounds("frame").right - cutout, bounds("screen-body").right) + } + + // ---- the body column: one start line for every screen ---- + + /** Where the area the frame leaves for the screen starts, and how wide it is, from what the frame drew. */ + private fun area(list: Boolean): Pair { + val root = bounds("frame") + val hairline = DdsBorder.hairline.dp + val left = when { + list -> bounds(DdsTestTags.ListPane).right + hairline + rule.onAllNodesWithTag(DdsTestTags.Rail).fetchSemanticsNodes().isNotEmpty() -> bounds(DdsTestTags.Rail).right + hairline + else -> 0.dp + } + return left to (root.right - left) + } + + private val bodyScreen: @Composable (PaddingValues) -> Unit = { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + DdsBodyColumn { Text("본문", Modifier.testTag("body-text")) } + } + } + + private fun assertBodyIsCentered(size: DpSize, list: Boolean) { + frame(size, list = list, body = bodyScreen) + val (areaLeft, areaWidth) = area(list) + val body = bounds(DdsTestTags.Body) + val width = minOf(DdsScreen.bodyMax.dp, areaWidth) + assertNear("body width at $size list=$list", width, body.right - body.left) + assertNear("body left at $size list=$list", areaLeft + (areaWidth - width) / 2, body.left) + } + + @Test + fun theBodyIsOneColumnInTheMiddleOfWhatTheFrameLeaves() { + assertBodyIsCentered(DpSize(933.dp, 704.dp), list = false) + val body = bounds(DdsTestTags.Body) + assertNear("on a wide window without a list the body is a full reading column", DdsScreen.bodyMax.dp, body.right - body.left) + } + + @Test + fun theBodyFillsWhatTheListLeavesOnAWideWindow() = assertBodyIsCentered(DpSize(933.dp, 704.dp), list = true) + + @Test + fun theBodyFillsAMediumWindowBesideTheRail() = assertBodyIsCentered(DpSize(704.dp, 933.dp), list = false) + + @Test + fun theBodyFillsAPhone() { + assertBodyIsCentered(DpSize(475.dp, 751.dp), list = false) + val body = bounds(DdsTestTags.Body) + assertNear("left", 0.dp, body.left) + assertNear("width", 475.dp, body.right - body.left) + } + + private fun assertTextStartsOnTheGutter(size: DpSize, list: Boolean) { + frame(size, list = list, body = bodyScreen) + val gutter = if (size.width < DdsScreen.breakpoint.dp) DdsScreen.gutter.dp else DdsScreen.gutterWide.dp + assertNear("the text starts the gutter in from the body at $size", bounds(DdsTestTags.Body).left + gutter, bounds("body-text").left) + } + + @Test + fun theTextInTheBodyStartsOnTheGutter() = assertTextStartsOnTheGutter(DpSize(475.dp, 751.dp), list = false) + + @Test + fun theTextInTheBodyStartsOnTheWideGutterOnAMediumWindow() = assertTextStartsOnTheGutter(DpSize(704.dp, 933.dp), list = false) + + @Test + fun theTextInTheBodyStartsOnTheWideGutterOnAWideWindow() = assertTextStartsOnTheGutter(DpSize(933.dp, 704.dp), list = false) + + @Test + fun theTextInTheBodyStartsOnTheWideGutterBesideAList() = assertTextStartsOnTheGutter(DpSize(933.dp, 704.dp), list = true) + + private fun assertABarOnTheBodysLinesLinesUpWithTheText(size: DpSize, list: Boolean) { + frame(size, list = list, body = { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + DdsBodyColumn { Text("본문", Modifier.fillMaxWidth().testTag("body-text")) } + Box(Modifier.align(Alignment.BottomCenter)) { + Box(Modifier.ddsBodyLines().height(8.dp).testTag("bar")) + } + } + }) + val text = bounds("body-text") + val bar = bounds("bar") + assertNear("bar left at $size list=$list", text.left, bar.left) + assertNear("bar right at $size list=$list", text.right, bar.right) + } + + @Test + fun aBarOnTheBodysLinesStartsWhereTheBodyTextStarts() { + assertABarOnTheBodysLinesLinesUpWithTheText(DpSize(933.dp, 704.dp), list = false) + } + + @Test + fun aBarOnTheBodysLinesLinesUpOnAPhone() = assertABarOnTheBodysLinesLinesUpWithTheText(DpSize(475.dp, 751.dp), list = false) + + @Test + fun aBarOnTheBodysLinesLinesUpBesideAList() = assertABarOnTheBodysLinesLinesUpWithTheText(DpSize(933.dp, 704.dp), list = true) + + @Test + fun theBodyColumnScrollsWhenItsContentIsTaller() { + frame(DpSize(475.dp, 751.dp), body = { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + DdsBodyColumn { repeat(40) { Text("줄 $it", Modifier.height(48.dp).testTag("row-$it")) } } + } + }) + rule.onNodeWithTag("row-39").performScrollTo().assertIsDisplayed() + } + + @Test + fun theBodySlidesWhenTheScreenArrives() { + assumeFalse("animations are turned off on this device", isReducedMotion(InstrumentationRegistry.getInstrumentation().targetContext)) + var screen by mutableStateOf("a") + var move by mutableStateOf(DdsMove.None) + rule.setContent { + DdsTheme { + DdsScreenHost(screen, move) { shown -> + DdsBodyColumn { Text("본문 $shown", Modifier.testTag("text-$shown")) } + } + } + } + fun left(shown: String) = rule.onNodeWithTag("text-$shown", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.left + rule.mainClock.autoAdvance = false + move = DdsMove.Push + screen = "b" + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + val arriving = left("b") + rule.mainClock.autoAdvance = true + rule.waitForIdle() + val settled = left("b") + assertTrue("the body starts to the trailing side of where it settles: $arriving vs $settled", arriving > settled + 1f) + } + + // ---- the top bar: on the body's lines, and the tall bar ---- + + private val backWord: String get() = InstrumentationRegistry.getInstrumentation().targetContext.getString(R.string.dds_a11y_back) + + private val noInsets = WindowInsetsCompat.Builder().build() + + private val phone = DpSize(475.dp, 751.dp) + private val wide = DpSize(933.dp, 704.dp) + + /** The window's gutter at [size], from the width class. */ + private fun gutterAt(size: DpSize): Dp = if (size.width < DdsScreen.breakpoint.dp) DdsScreen.gutter.dp else DdsScreen.gutterWide.dp + + /** + * A frame at [size] with a screen whose top bar is [topBar] (tagged `bar`) over a body column, no system + * insets, at font [scale]; [barTitles] and [barReserve] go to the frame. + */ + private fun barScreen( + size: DpSize, + scale: Float = 1f, + barTitles: () -> List = { emptyList() }, + barReserve: (String) -> Dp = { 0.dp }, + density: Float? = null, + insets: WindowInsetsCompat = noInsets, + topBar: @Composable () -> Unit, + ) { + rule.setContent { + // The window is forced in pixels of the target density, then the content is given that density + // (innermost, since the font scale override puts the device's density back). + val root = LocalDensity.current + val ratio = (density ?: root.density) / root.density + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(DpSize(size.width * ratio, size.height * ratio))) { + DeviceConfigurationOverride(DeviceConfigurationOverride.WindowInsets(insets)) { + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(scale)) { + CompositionLocalProvider(LocalDensity provides LocalDensity.current.let { Density(density ?: it.density, it.fontScale) }) { + DdsTheme { + DdsAppFrame( + navItems = four, + selectedIndex = 0, + onSelect = {}, + modifier = Modifier.testTag("frame"), + barTitles = barTitles(), + barReserve = barReserve, + ) { + DdsScaffold(topBar = topBar, bottomTabs = false) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { DdsBodyColumn { Text("본문") } } + } + } + } + } + } + } + } + } + } + + + private fun assertTitleLeft(expected: Dp, message: String) = + assertEquals(message, expected.value, bounds(DdsTestTags.TopBarTitle).left.value, 1f) // a few pixel roundings add up + + private fun assertArrowAndLastIconOnTheLines(size: DpSize) { + barScreen(size) { + DdsTopBar("거래처", Modifier.testTag("bar"), onBack = {}) { + DdsIconButton(DdsIcons.Search, "검색", {}) + DdsIconButton(DdsIcons.Plus, "추가", {}, Modifier.testTag("last-action")) + } + } + val body = bounds(DdsTestTags.Body) + val gutter = gutterAt(size) + val back = rule.onNodeWithContentDescription(backWord).getUnclippedBoundsInRoot() + val hang = (back.right - back.left - DdsIcon.md.dp) / 2 + // Rounding the gutter, the button and the glyph to pixels each costs up to half a pixel. + val pixel = 1.5f / rule.density.density + assertEquals("the arrow's glyph starts on the body's text line at $size", (body.left + gutter).value, (back.left + hang).value, pixel) + val last = bounds("last-action") + assertEquals("the last icon's glyph ends on the body's end line at $size", (body.right - gutter).value, (last.right - (last.right - last.left - DdsIcon.md.dp) / 2).value, pixel) + } + + @Test + fun theBackArrowAndTheLastIconStandOnTheBodysLines() = assertArrowAndLastIconOnTheLines(wide) + + @Test + fun theBackArrowAndTheLastIconStandOnTheBodysLinesOnAPhone() = assertArrowAndLastIconOnTheLines(phone) + + private fun assertTheTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow(size: DpSize, density: Float? = null) { + var detail by mutableStateOf(false) + barScreen(size, density = density) { + DdsTopBar( + "거래처", + Modifier.testTag("bar"), + leading = { Box(Modifier.size(DdsIcon.md.dp).testTag("mark")) }, + onBack = if (detail) ({}) else null, + ) + } + val d = density ?: rule.density.density + // Pixels, from the unclipped bounds (the forced window is larger than the screen). + fun left(tag: String) = bounds(tag).left.value * d // the node reports dp of its own density + val bodyLeft = left(DdsTestTags.Body) + val afterMark = left(DdsTestTags.TopBarTitle) + assertEquals("the mark starts on the text line at $size density $d", bodyLeft + gutterAt(size).value * d, left("mark"), d) + detail = true + rule.waitForIdle() + val afterArrow = left(DdsTestTags.TopBarTitle) + // The same pixel, not the same within a dp: going from a tab screen to a detail must not move the title. + assertEquals("the title's x after a mark and after an arrow at $size density $d", afterMark, afterArrow, 0.5f) + assertEquals("the title is gutter + 40dp in from the body at $size density $d", bodyLeft + (gutterAt(size).value + DdsSpace.s40) * d, afterArrow, d) + } + + @Test + fun theTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow() = assertTheTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow(wide) + + @Test + fun theTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrowOnAPhone() = assertTheTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow(phone) + + // Densities where rounding the pieces one by one disagrees with rounding the sum (1.0625: 59px vs 60px on a phone, 68 vs 69 on a wider window). + @Test + fun theTitleDoesNotMoveAtDensity1p0625OnAPhone() = assertTheTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow(phone, 1.0625f) + + @Test + fun theTitleDoesNotMoveAtDensity1p0625OnAMediumWindow() = assertTheTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow(DpSize(704.dp, 933.dp), 1.0625f) + + @Test + fun theTitleDoesNotMoveAtDensity1p3() = assertTheTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow(phone, 1.3f) + + @Test + fun theTitleDoesNotMoveAtDensity1p5OnAMediumWindow() = assertTheTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow(DpSize(704.dp, 933.dp), 1.5f) + + @Test + fun theHangingPartsOfTheArrowAndTheLastIconCanBeTapped() { + var backs = 0 + var lasts = 0 + barScreen(wide) { + DdsTopBar("거래처", Modifier.testTag("bar"), onBack = { backs++ }) { + DdsIconButton(DdsIcons.Search, "검색", {}) + DdsIconButton(DdsIcons.Plus, "추가", { lasts++ }, Modifier.testTag("last-action")) + } + } + // 6dp in from the outer edge is in the 12dp the touch area reaches past the glyph. + rule.onNodeWithContentDescription(backWord).performTouchInput { click(Offset(6.dp.toPx(), centerY)) } + rule.onNodeWithTag("last-action").performTouchInput { click(Offset(width - 6.dp.toPx(), centerY)) } + assertEquals("taps on the arrow's hanging strip", 1, backs) + assertEquals("taps on the last icon's hanging strip", 1, lasts) + } + + @Test + fun aTitleWithNothingBeforeItStartsOnTheBodysLine() { + barScreen(wide) { DdsTopBar("거래처", Modifier.testTag("bar")) } + assertTitleLeft(bounds(DdsTestTags.Body).left + gutterAt(wide), "a bare title on a wide window") + } + + @Test + fun aTitleWithNothingBeforeItStartsOnTheBodysLineOnAPhone() { + barScreen(phone) { DdsTopBar("거래처", Modifier.testTag("bar")) } + assertTitleLeft(bounds(DdsTestTags.Body).left + gutterAt(phone), "a bare title on a phone") + } + + @Test + fun aBarWithNoActionsAndOneWithAButtonPassedAsLeadingDoNotBreak() { + var asLeading by mutableStateOf(false) + barScreen(phone) { + DdsTopBar( + "거래처", + Modifier.testTag("bar"), + leading = if (asLeading) ({ DdsIconButton(DdsIcons.ArrowLeft, "뒤로 가기", {}, Modifier.testTag("old-back")) }) else null, + ) + } + val bar = bounds("bar") + fun assertTitleInsideTheBar(message: String) { + val title = bounds(DdsTestTags.TopBarTitle) + assertTrue("$message: the title $title is inside the bar $bar", title.left >= bar.left && title.right <= bar.right && title.top >= bar.top && title.bottom <= bar.bottom) + } + assertTitleInsideTheBar("with no actions") + asLeading = true + rule.waitForIdle() + assertTitleInsideTheBar("with a button as leading") + val old = bounds("old-back") + assertNear("the old back button is 48dp wide", DdsTouch.min.dp, old.right - old.left) + assertTrue("the title ${bounds(DdsTestTags.TopBarTitle)} does not overlap the button $old", bounds(DdsTestTags.TopBarTitle).left >= old.right) + } + + private val longName = "거래처별 매출과 미수금 현황을 한눈에 보는 월간 종합 보고서 화면" + + @Test + fun oneLongNameMakesEveryBarInTheWindowTall() { + var titles by mutableStateOf(emptyList()) + var title by mutableStateOf(longName) + barScreen(phone, scale = 1.5f, barTitles = { titles }) { DdsTopBar(title, Modifier.testTag("bar")) } + fun barHeight() = bounds("bar").let { it.bottom - it.top - DdsBorder.hairline.dp } + assertNear("without barTitles the bar is the short one", DdsBar.top.dp, barHeight()) + val cut = layoutOf(rule.onNodeWithTag(DdsTestTags.TopBarTitle)) + assertEquals("without barTitles the long title is on one line", 1, cut.lineCount) + assertTrue("and it is cut", cut.isLineEllipsized(0)) + + titles = listOf(longName, "홈") + rule.waitForIdle() + assertNear("with a name that needs two lines the bar is tall", DdsBar.topTall.dp, barHeight()) + assertEquals("the long title is on two lines", 2, layoutOf(rule.onNodeWithTag(DdsTestTags.TopBarTitle)).lineCount) + + title = "홈" + rule.waitForIdle() + assertNear("a short title on another screen has the same tall bar", DdsBar.topTall.dp, barHeight()) + } + + @Test + fun barReserveTakesRoomFromTheTitle() { + var reserve by mutableStateOf(0.dp) + val title = "거래처 매출 관리 현황 보고서" + barScreen(phone, barTitles = { listOf(title) }, barReserve = { reserve }) { DdsTopBar(title, Modifier.testTag("bar")) } + fun barHeight() = bounds("bar").let { it.bottom - it.top - DdsBorder.hairline.dp } + assertNear("a title that fits alone keeps the short bar", DdsBar.top.dp, barHeight()) + reserve = 120.dp + rule.waitForIdle() + assertNear("with 120dp kept for something else the same title needs two lines", DdsBar.topTall.dp, barHeight()) + } + + @Test + fun aShortBarAndALongBarAreTheSameHeightAtAVeryLargeFont() { + var title by mutableStateOf("홈") + barScreen(phone, scale = 3f, barTitles = { listOf(longName) }) { DdsTopBar(title, Modifier.testTag("bar")) } + fun barHeight() = bounds("bar").let { it.bottom - it.top - DdsBorder.hairline.dp } + val short = barHeight() + assertTrue("the tall bar is at least ${DdsBar.topTall}dp, was $short", short >= DdsBar.topTall.dp - 0.6.dp) + title = longName + rule.waitForIdle() + assertEquals("the long title takes two lines", 2, layoutOf(rule.onNodeWithTag(DdsTestTags.TopBarTitle)).lineCount) + assertNear("a long title does not make its bar taller than a short one's", short, barHeight()) + } + + @Test + fun aMediumWindowWithTheRailGivesTheTitleWhatIsLeftBesideTheRail() { + val size = DpSize(640.dp, 900.dp) + var reserve by mutableStateOf(0.dp) + val title = "거래처 매출 관리 현황 보고서" + barScreen(size, barTitles = { listOf(title) }, barReserve = { reserve }) { DdsTopBar(title, Modifier.testTag("bar")) } + fun barHeight() = bounds("bar").let { it.bottom - it.top - DdsBorder.hairline.dp } + assertNear("the title fits at first", DdsBar.top.dp, barHeight()) + val wanted = with(rule.density) { layoutOf(rule.onNodeWithTag(DdsTestTags.TopBarTitle)).multiParagraph.maxIntrinsicWidth.toDp() } + // Beside the rail the title has the window less the rail and its hairline, the gutters, the arrow's room and two icons. + val room = size.width - (DdsBar.rail + DdsBorder.hairline).dp - gutterAt(size) * 2 - DdsSpace.s40.dp - DdsTouch.min.dp * 2 + reserve = room - wanted + 20.dp + rule.waitForIdle() + assertNear("the title is 20dp short of fitting beside the rail, though it would fit without it", DdsBar.topTall.dp, barHeight()) + } + + @Test + fun aCutoutAtTheSidesTakesRoomFromTheTitle() { + val title = "거래처 매출 관리 현황 보고서" + val cutouts = with(rule.density) { Insets.of(150.dp.roundToPx(), 0, 150.dp.roundToPx(), 0) } + barScreen( + phone, + barTitles = { listOf(title) }, + insets = WindowInsetsCompat.Builder().setInsets(WindowInsetsCompat.Type.displayCutout(), cutouts).build(), + ) { DdsTopBar(title, Modifier.testTag("bar")) } + val height = bounds("bar").let { it.bottom - it.top - DdsBorder.hairline.dp } + assertNear("150dp of cutout on each side leaves the title too little room", DdsBar.topTall.dp, height) + } + + // ---- the centered block: a screen with one thing to do ---- + + /** + * A centered block holding [content] in a window of [size] at font [scale]: inside a frame with + * its navigation off, or with no frame at all. + */ + private fun centered( + size: () -> DpSize, + scale: Float = 1f, + inFrame: Boolean = true, + onWindow: (Dp) -> Unit = {}, + content: @Composable ColumnScope.() -> Unit, + ) { + rule.setContent { + // The font scale goes outside: its override puts the device's density back, the forced size keeps the scale. + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(scale)) { + // No bars and no keyboard here: the room the block is centered in is the whole window. + // (Outside the forced size: inside it, a change of the forced size does not reach the content.) + DeviceConfigurationOverride(DeviceConfigurationOverride.WindowInsets(noInsets)) { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size())) { + DdsTheme { + // The window as the library reads it outside a frame: LocalWindowInfo in this density. + onWindow(with(LocalDensity.current) { LocalWindowInfo.current.containerSize.width.toDp() }) + if (inFrame) { + DdsAppFrame(four, 0, {}, navigation = false) { DdsCenteredColumn(content = content) } + } else { + DdsCenteredColumn(content = content) + } + } + } + } + } + } + } + + /** The block is [width] wide with the same space on its two sides, and the same above and below. */ + private fun assertTheBlockIsInTheMiddle(message: String, width: Dp) { + val area = bounds(DdsTestTags.CenteredArea) + val block = bounds(DdsTestTags.Centered) + assertNear("$message: the block's width", width, block.right - block.left) + // Centering lands on a whole pixel, which in a forced window can be most of a dp. + assertEquals("$message: the space at its two sides", (block.left - area.left).value, (area.right - block.right).value, 1f) + assertEquals("$message: the space above and below", (block.top - area.top).value, (area.bottom - block.bottom).value, 1f) + assertTrue("$message: it does not start at the top", block.top - area.top > DdsScreen.blockGap.dp) + } + + @Test + fun theOneBlockIsInTheMiddleAndNoWiderThanItsLimit() { + var size by mutableStateOf(wide) + centered({ size }) { Text("한 가지 일", Modifier.fillMaxWidth().testTag("only")) } + val window = bounds(DdsTestTags.CenteredArea) + assertEquals("the area is the window", wide.width.value, (window.right - window.left).value, 1f) + assertTheBlockIsInTheMiddle("on a wide window", DdsScreen.centeredMax.dp) + + // A folded phone's 475dp still has room for the whole 400dp block. + size = phone + rule.waitForIdle() + val folded = bounds(DdsTestTags.CenteredArea) + assertEquals("the area is the window", phone.width.value, (folded.right - folded.left).value, 1f) + assertTheBlockIsInTheMiddle("on a folded phone", DdsScreen.centeredMax.dp) + + // A window narrower than the block and its gutters: the block is what the gutters leave. + size = small + rule.waitForIdle() + val area = bounds(DdsTestTags.CenteredArea) + assertEquals("the area is the window", small.width.value, (area.right - area.left).value, 1f) + assertTheBlockIsInTheMiddle("on a small phone", area.right - area.left - DdsScreen.gutter.dp * 2) + assertNear("on a small phone the block keeps the gutter", area.left + DdsScreen.gutter.dp, bounds(DdsTestTags.Centered).left) + } + + /** A phone narrower than the centered block's limit and its two gutters. */ + private val small = DpSize(360.dp, 751.dp) + + @Test + fun theOneBlockWorksWithoutAFrame() { + // Outside a frame the gutter comes from the window (LocalWindowInfo), which a forced size does not change: + // the expected gutter is the one of the window the test really gets, classified as the library does. + var window = 0.dp + centered({ small }, inFrame = false, onWindow = { window = it }) { Text("한 가지 일", Modifier.fillMaxWidth()) } + val gutter = ddsWindowWidth(window).gutter + val area = bounds(DdsTestTags.CenteredArea) + val areaWidth = area.right - area.left + assertEquals("the area is the forced size", small.width.value, areaWidth.value, 1f) + val width = minOf(DdsScreen.centeredMax.dp, areaWidth - gutter * 2) + assertTheBlockIsInTheMiddle("with no frame (window ${window.value}dp)", width) + assertNear("with no frame the block keeps the window's gutter (${gutter.value}dp)", area.left + gutter, bounds(DdsTestTags.Centered).left) + } + + @Test + fun aBlockTallerThanTheWindowStartsAtTheTopAndScrolls() { + centered({ phone }, scale = 2f) { + repeat(10) { Text("줄 ${it + 1}\n둘째 줄", Modifier.testTag("line-$it"), style = DdsTheme.typography.title1) } + } + val area = bounds(DdsTestTags.CenteredArea) + val gap = DdsScreen.blockGap.dp + assertEquals("the area is the window, however tall the block", phone.height.value, (area.bottom - area.top).value, 1f) + assertNear("the first line starts under the top padding, not cut above the window", area.top + gap, bounds("line-0").top) + assertTrue("the last line starts below the window, so the block is taller than it", bounds("line-9").top > area.bottom) + rule.onNodeWithTag("line-9").performScrollTo().assertIsDisplayed() + // To the very end: more than there is left to scroll. + rule.onNode(hasScrollAction()).performSemanticsAction(SemanticsActions.ScrollBy) { it(0f, 100_000f) } + rule.waitForIdle() + assertNear("scrolled to the end, the last line stands on the bottom padding", area.bottom - gap, bounds("line-9").bottom) + } + + @Test + fun aCenteredTitleKeepsItsLineBreaks() { + centered({ wide }) { + DdsCenteredTitle("정산을\n시작합니다", Modifier.testTag("two")) + DdsCenteredTitle("정산을 시작합니다", Modifier.testTag("one")) + } + val two = bounds("two") + val one = bounds("one") + assertTrue("a title with a line break is a line taller: $two against $one", (two.bottom - two.top) > (one.bottom - one.top) + 20.dp) + val block = bounds(DdsTestTags.Centered) + val words = rule.onNodeWithText("정산을 시작합니다", useUnmergedTree = true).getUnclippedBoundsInRoot() + assertNear("the title's words are in the middle of the block", (block.left + block.right) / 2, (words.left + words.right) / 2) + + // Each line of the two-line title is centered, not only the title as a whole: the ink of the short first + // line and of the longer second one are both around the middle. + val pixels = rule.onNodeWithTag("two").captureToImage().toPixelMap() + val paper = pixels[0, 0] + fun ink(rows: IntRange): IntRange { + val columns = (0 until pixels.width).filter { x -> rows.any { y -> pixels[x, y] != paper } } + return columns.first()..columns.last() + } + val first = ink(0 until pixels.height / 2) + val second = ink(pixels.height / 2 until pixels.height) + val widthOf = { line: IntRange -> line.last - line.first } + assertTrue("the first line ($first) is shorter than the second ($second)", widthOf(second) > widthOf(first) + 16) + assertEquals("the first line's ink is centered in ${pixels.width}px", pixels.width / 2f, (first.first + first.last) / 2f, 4f) + assertEquals("the second line's ink is centered in ${pixels.width}px", pixels.width / 2f, (second.first + second.last) / 2f, 4f) + } + + // ---- the rows of the pinned list ---- + + /** A frame at [size] whose pinned list (or, on a narrow window, its one screen) is a pane list of [rows]. */ + private fun pane( + size: DpSize = wide, + scale: Float = 1f, + largeText: () -> Boolean = { false }, + rows: @Composable ColumnScope.() -> Unit, + ) { + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(scale)) { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size)) { + DdsTheme(largeText = largeText()) { + val pinned = size.width >= DdsScreen.breakpointExpanded.dp + DdsAppFrame(four, 0, {}, Modifier.testTag("frame"), list = { DdsPaneList(content = rows) }) { + DdsScaffold(topBar = {}) { padding -> + Box(Modifier.fillMaxSize().padding(padding).testTag("screen-body")) { + if (!pinned) DdsPaneList(content = rows) + } + } + } + } + } + } + } + } + + private fun height(tag: String): Dp = bounds(tag).let { it.bottom - it.top } + + private fun label(text: String) = rule.onNodeWithText(text, useUnmergedTree = true).getUnclippedBoundsInRoot() + + @Test + fun theItemChosenInTheListLooksLikeTheRailsChoice() { + pane { + DdsPaneItem("매출 현황", selected = true, onClick = {}, modifier = Modifier.testTag("chosen")) + DdsPaneItem("매출 현황", selected = false, onClick = {}, modifier = Modifier.testTag("other")) + } + val pixels = rule.onRoot().captureToImage().toPixelMap() + fun px(tag: String) = rule.onNodeWithTag(tag).fetchSemanticsNode().boundsInRoot + val rail = px(DdsTestTags.Rail) + val dp = rail.width / DdsBar.rail // pixels to a dp in this forced window + val screen = px("screen-body") + val page = pixels[(screen.right - 8 * dp).toInt(), screen.center.y.toInt()] + + // The rail's selected pill is 56dp wide around a 24dp icon: 20dp left of the middle is pill, clear of the icon. + val home = rule.onNodeWithText("홈").fetchSemanticsNode().boundsInRoot + val x = (home.center.x - 20 * dp).toInt() + val pillTop = (home.top.toInt() until home.bottom.toInt()).first { pixels[x, it] != page } + val pill = pixels[x, (pillTop + 8 * dp).toInt()] + assertTrue("the rail's pill $pill is not the page $page", pill != page) + + val chosen = px("chosen") + val other = px("other") + val fill = pixels[(chosen.right - 16 * dp).toInt(), chosen.center.y.toInt()] + assertEquals("the chosen row is filled with the color of the rail's pill", pill, fill) + assertEquals("a row that is not chosen has no fill", page, pixels[(other.right - 16 * dp).toInt(), other.center.y.toInt()]) + + // Not by color alone: the same words are drawn heavier. Count what is not background in the middle band of each row. + fun ink(row: androidx.compose.ui.geometry.Rect, paper: androidx.compose.ui.graphics.Color): Int { + var count = 0 + for (py in (row.top + row.height / 4).toInt() until (row.bottom - row.height / 4).toInt()) { + for (qx in (row.left + 4 * dp).toInt() until (row.right - 4 * dp).toInt()) if (pixels[qx, py] != paper) count++ + } + return count + } + val heavy = ink(chosen, fill) + val light = ink(other, page) + assertTrue("the unchosen label is drawn: $light pixels", light > 0) + assertTrue("the chosen label is heavier: $heavy pixels of ink against $light", heavy > light * 1.05f) + assertNear("being chosen does not change the row's height", height("other"), height("chosen")) + } + + @Test + fun aListItemsLabelStandsOnTheGutterLine() { + pane { + DdsPaneItem("매출 현황", selected = true, onClick = {}) + DdsPaneItem("미수금", selected = false, onClick = {}) + } + val line = bounds(DdsTestTags.ListPane).left + DdsScreen.gutterWide.dp + assertNear("the chosen row's label starts on the pane's gutter line", line, label("매출 현황").left) + assertNear("another row's label starts on the same line", line, label("미수금").left) + } + + @Test + fun aListShownAsAScreenOnAPhoneStandsOnThePhonesGutter() { + pane(size = phone) { DdsPaneItem("매출 현황", selected = true, onClick = {}) } + assertNear("the label starts on the phone's gutter", bounds("screen-body").left + DdsScreen.gutter.dp, label("매출 현황").left) + } + + @Test + fun aListItemIsAControlTallAndTallerInLargeTextMode() { + var large by mutableStateOf(false) + pane(largeText = { large }) { DdsPaneItem("매출 현황", selected = false, onClick = {}, modifier = Modifier.testTag("row")) } + assertNear("a row's height", DdsControl.md.dp, height("row")) + large = true + rule.waitForIdle() + assertNear("a row's height in large text mode", DdsTouch.large.dp, height("row")) + } + + @Test + fun aLongListLabelWrapsAtALargeFontInsteadOfBeingCut() { + val long = "거래처별 매출과 미수금 현황 보고서" + pane(scale = 2f) { + DdsPaneItem("미수금", selected = false, onClick = {}, modifier = Modifier.testTag("short")) + DdsPaneItem(long, selected = false, onClick = {}, modifier = Modifier.testTag("long")) + DdsPaneItem("$long 둘", selected = true, onClick = {}, modifier = Modifier.testTag("long-chosen")) + } + val oneLine = label("미수금").let { it.bottom - it.top } + assertTrue("the font is twice its size: a line is $oneLine", oneLine > 40.dp) + val words = label(long) + assertTrue("the long label takes more lines than one ($oneLine): ${words.bottom - words.top}", words.bottom - words.top > oneLine * 1.5f) + val pane = bounds(DdsTestTags.ListPane) + for (tag in listOf("long", "long-chosen")) { + val row = bounds(tag) + val text = label(if (tag == "long") long else "$long 둘") + assertTrue("the label $text is inside its row $row", text.top >= row.top && text.bottom <= row.bottom && text.left >= row.left && text.right <= row.right) + assertTrue("the row $row is inside the pane $pane", row.left >= pane.left && row.right <= pane.right) + } + assertTrue("the row grew with its label", height("long") > height("short")) + } + + @Test + fun theListScrollsWhenItsRowsDoNotFit() { + pane { repeat(30) { DdsPaneItem("항목 ${it + 1}", selected = it == 0, onClick = {}) } } + rule.onNodeWithText("항목 30").performScrollTo().assertIsDisplayed() + } + + // ---- a whole sample app, walked with the check apps use ---- + + /** The screens of the sample app, and what kind each one is. */ + private enum class Stop(val kind: DdsScreenKind, val title: String) { + Home(DdsScreenKind.TopLevel, "홈"), + Customers(DdsScreenKind.TopLevel, "거래처"), + Order(DdsScreenKind.Inner, "주문 상세"), + + /** The sales area's list as a screen of its own: what a window too narrow to pin it shows. */ + SalesList(DdsScreenKind.Inner, "매출"), + Sales(DdsScreenKind.Listed, "매출 현황"), + Receivables(DdsScreenKind.Listed, "미수금"), + Scanner(DdsScreenKind.WholeWindow, "스캔"), + Gate(DdsScreenKind.WholeWindow, "시작"), + } + + private val salesArea = setOf(Stop.SalesList, Stop.Sales, Stop.Receivables) + + /** A product's page: warm paper instead of the system's white, written the way an app writes it in code. */ + private val paper = Color(red = 0.98f, green = 0.96f, blue = 0.91f) + + /** That product's brand: the default brand on its own page. Every contrast pair still passes, so it declares no exception. */ + private val paperBrand = DdsBrand(light = DdsBrand.Default.light.copy(bgDefault = paper), dark = DdsBrand.Default.dark) + + @Composable + private fun SalesRows(stop: Stop, go: (Stop) -> Unit) { + DdsPaneList { + DdsPaneItem(Stop.Sales.title, selected = stop == Stop.Sales, onClick = { go(Stop.Sales) }) + DdsPaneItem(Stop.Receivables.title, selected = stop == Stop.Receivables, onClick = { go(Stop.Receivables) }) + } + } + + /** + * A small app built the way the guide says: two tabs, a screen opened from one, an area with a list + * (pinned beside its screens on a wide window, a screen of its own otherwise), a screen that takes the + * whole window, and a gate with one thing to do. + */ + @Composable + private fun SampleApp(stop: Stop, move: DdsMove, go: (Stop) -> Unit) { + DdsAppFrame( + navItems = items(Stop.Home.title, Stop.Customers.title), + selectedIndex = if (stop == Stop.Customers) 1 else 0, + onSelect = { go(if (it == 1) Stop.Customers else Stop.Home) }, + modifier = Modifier.testTag("frame"), + navigation = stop != Stop.Scanner && stop != Stop.Gate, + list = if (stop in salesArea) ({ + DdsScaffold(topBar = { DdsTopBar(Stop.SalesList.title) }) { padding -> + Box(Modifier.padding(padding)) { SalesRows(stop, go) } + } + }) else null, + ) { + DdsScreenHost(stop, move) { shown -> + when (shown) { + Stop.Home, Stop.Customers -> DdsScaffold( + topBar = { DdsTopBar(shown.title, Modifier.testTag("bar"), leading = { Box(Modifier.size(DdsIcon.md.dp)) }) }, + bottomTabs = true, + ) { padding -> Box(Modifier.fillMaxSize().padding(padding).testTag("page")) { DdsBodyColumn { Text("${shown.title} 본문") } } } + + // A screen that lays its own body out: the list fills the body column. + Stop.SalesList -> DdsScaffold(topBar = { DdsTopBar(shown.title, onBack = { go(Stop.Home) }) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { + Box(Modifier.ddsBody().fillMaxWidth()) { SalesRows(stop, go) } + } + } + + Stop.Gate -> DdsCenteredColumn { + DdsCenteredTitle("정산을\n시작합니다", description = "거래처와 주문을 한곳에서 봅니다") + } + + else -> DdsScaffold( + topBar = { + // Beside its pinned list a screen of the area has nowhere to go back to. + val back: (() -> Unit)? = if (shown.kind == DdsScreenKind.Listed && ddsFrame().listPinned) null else ({ go(Stop.Home) }) + DdsTopBar(shown.title, Modifier.testTag("bar"), onBack = back) { DdsIconButton(DdsIcons.Search, "검색", {}) } + }, + ) { padding -> Box(Modifier.fillMaxSize().padding(padding).testTag("page")) { DdsBodyColumn { Text("${shown.title} 본문") } } } + } + } + } + } + + @Test + fun everyScreenOfTheSampleAppKeepsTheFrame() { + var size by mutableStateOf(phone) + var stop by mutableStateOf(Stop.Home) + var move by mutableStateOf(DdsMove.None) + var brand by mutableStateOf(DdsBrand.Default) + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size)) { + DdsTheme(darkTheme = false, brand = brand) { SampleApp(stop, move) { stop = it } } + } + } + val check = DdsFrameCheck(rule) + fun open(next: Stop) { + move = when { + next.kind == DdsScreenKind.TopLevel && stop.kind == DdsScreenKind.TopLevel -> DdsMove.Tab + next.kind == DdsScreenKind.TopLevel -> DdsMove.Pop + else -> DdsMove.Push + } + stop = next + rule.waitForIdle() + } + val medium = DpSize(704.dp, 933.dp) + for (window in listOf(phone, medium, wide)) { + size = window + rule.waitForIdle() + for (next in Stop.entries) { + // On a wide window the list is pinned beside its screens; it is never a screen of its own there. + if (next == Stop.SalesList && window == wide) continue + open(next) + check.look("${next.name} at ${window.width.value.toInt()}dp", next.kind) + } + open(Stop.Home) + } + assertEquals("every screen at every width was looked at", 3 * Stop.entries.size - 1, check.readings.size) + + // The same app under a product's own page color: the lines are the same, and the frame shows the page. + brand = paperBrand + fun pixel(pixels: androidx.compose.ui.graphics.PixelMap, x: Float, y: Float) = pixels[x.toInt(), y.toInt()].toArgb() + val paperInk = paper.toArgb() // what a screen holds of it: eight bits a channel + fun px(tag: String) = rule.onNodeWithTag(tag, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + open(Stop.Sales) // still on the wide window: the rail, the pinned list and two top bars + check.look("Sales on a product's page", Stop.Sales.kind) + val dp = px(DdsTestTags.Rail).width / DdsBar.rail // pixels to a dp in this forced window + var pixels = rule.onRoot().captureToImage().toPixelMap() + val rail = px(DdsTestTags.Rail) + val list = px(DdsTestTags.ListPane) + val bar = px("bar") + val page = px("page") + assertEquals("the rail's background, under its items", paperInk, pixel(pixels, rail.center.x, rail.bottom - 24 * dp)) + assertEquals("the pinned list's background, under its rows", paperInk, pixel(pixels, list.center.x, list.bottom - 24 * dp)) + assertEquals("the pinned list's own top bar, at its end", paperInk, pixel(pixels, list.right - 4 * dp, bar.center.y)) + assertEquals("the screen's top bar, at its start", paperInk, pixel(pixels, bar.left + 4 * dp, bar.center.y)) + assertEquals("the screen under its body", paperInk, pixel(pixels, page.right - 8 * dp, page.bottom - 24 * dp)) + + size = phone + open(Stop.Home) + check.look("Home on a product's page", Stop.Home.kind) + pixels = rule.onRoot().captureToImage().toPixelMap() + val tabs = px(DdsTestTags.BottomTabs) + assertEquals("the bottom tabs' background, at their start", paperInk, pixel(pixels, tabs.left + 2 * dp, tabs.center.y)) + assertEquals("the top bar on a phone, at its end", paperInk, pixel(pixels, px("bar").right - 4 * dp, px("bar").center.y)) + + // Two screens of a kind, at one window width, start on the same line and are the same width. + check.assertSameLines() + } + + // ---- twice the font ---- + + private fun assertNoTextInTheFrameIsCut(size: DpSize) { + val title = "거래처별 매출과 미수금 관리 현황" + rule.setContent { + // The font scale goes outside: its override puts the device's density back, the forced size keeps the scale. + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(2f)) { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size)) { + DdsTheme { + DdsAppFrame( + navItems = items("홈", short), + selectedIndex = 0, + onSelect = {}, + barTitles = listOf(title, "목록"), + list = { DdsScaffold(topBar = { DdsTopBar("목록") }) { padding -> Text("목록의 줄", Modifier.padding(padding)) } }, + ) { + DdsScaffold( + topBar = { DdsTopBar(title, onBack = {}) { DdsIconButton(DdsIcons.Search, "검색", {}) } }, + bottomTabs = true, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + DdsBodyColumn { + Text("한 줄", Modifier.testTag("one-line")) + Text("거래처별 매출과 미수금 현황을 한눈에 보는 월간 종합 보고서입니다. 줄이 넘어가면 다음 줄로 이어집니다.") + } + } + } + } + } + } + } + } + val line = bounds("one-line").let { it.bottom - it.top } + assertTrue("the font is twice its size at $size: a line of body text is $line", line > 36.dp) + val texts = rule.onAllNodes(SemanticsMatcher.keyIsDefined(SemanticsActions.GetTextLayoutResult), useUnmergedTree = true) + val nodes = texts.fetchSemanticsNodes() + val words = nodes.map { node -> node.config.getOrNull(SemanticsProperties.Text)?.joinToString { it.text }.orEmpty() } + // The tabs' or the rail's two labels, the screen's title, the two lines of the body (and on a wide window the list's two). + assertTrue("the frame's texts were found at $size: $words", words.containsAll(listOf("홈", short, title, "한 줄"))) + for (index in nodes.indices) { + val layout = layoutOf(texts[index]) + // Cut is a line ending in an ellipsis, lines left out, or lines taller than the room they were given. + // (hasVisualOverflow is no use here: in a forced window it is true by a fraction of a pixel for text that is whole.) + val ellipsis = (0 until layout.lineCount).any { layout.isLineEllipsized(it) } + val short = layout.multiParagraph.height > layout.size.height + 1f + assertFalse( + "\"${words[index]}\" is cut at $size: ${layout.lineCount} line(s), ellipsis=$ellipsis, lines left out=${layout.multiParagraph.didExceedMaxLines}, " + + "text ${layout.multiParagraph.height}px tall in ${layout.size.height}px", + ellipsis || layout.multiParagraph.didExceedMaxLines || short, + ) + } + // On a phone the title does not fit one line at this size: it is whole because the bar gave it a second one. + if (size == phone) assertEquals("the title's lines on a phone", 2, layoutOf(rule.onNodeWithText(title, useUnmergedTree = true)).lineCount) + } + + @Test + fun atTwiceTheFontNothingInTheFrameIsCut() = assertNoTextInTheFrameIsCut(phone) + + @Test + fun atTwiceTheFontNothingInTheFrameIsCutOnAWideWindow() = assertNoTextInTheFrameIsCut(wide) + + // ---- the centered block: the bars, the keyboard, its gaps and its arrival ---- + + /** + * A centered block (in a frame tagged `frame`, navigation off) in a window of [size] at font [scale], + * under a status bar [top] tall and over a keyboard [bottom] tall. + */ + private fun centeredBetweenBars( + size: DpSize, + scale: Float = 1f, + top: Dp = 24.dp, + bottom: Dp = 300.dp, + content: @Composable ColumnScope.() -> Unit, + ) { + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.FontScale(scale)) { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size)) { + val insets = with(LocalDensity.current) { + WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, top.roundToPx(), 0, 0)) + .setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, bottom.roundToPx())) + .setVisible(WindowInsetsCompat.Type.ime(), true) + .build() + } + DeviceConfigurationOverride(DeviceConfigurationOverride.WindowInsets(insets)) { + DdsTheme { + DdsAppFrame(four, 0, {}, Modifier.testTag("frame"), navigation = false) { DdsCenteredColumn(content = content) } + } + } + } + } + } + } + + @Test + fun theOneBlockIsCenteredBetweenTheBarsAndTheKeyboard() { + centeredBetweenBars(phone) { Text("한 가지 일", Modifier.fillMaxWidth()) } + val frame = bounds("frame") + val area = bounds(DdsTestTags.CenteredArea) + val block = bounds(DdsTestTags.Centered) + val clearTop = frame.top + 24.dp + val clearBottom = frame.bottom - 300.dp + assertNear("the room the block is centered in starts under the status bar", clearTop, area.top) + assertNear("and ends above the keyboard", clearBottom, area.bottom) + assertEquals("the block is in the middle of what the bar and the keyboard leave", ((clearTop + clearBottom) / 2).value, ((block.top + block.bottom) / 2).value, 1f) + } + + @Test + fun aTallBlockStartsUnderTheStatusBarAndItsEndComesUpAboveTheKeyboard() { + centeredBetweenBars(phone, scale = 2f) { + repeat(10) { Text("줄 ${it + 1}\n둘째 줄", Modifier.testTag("line-$it"), style = DdsTheme.typography.title1) } + } + val frame = bounds("frame") + val gap = DdsScreen.blockGap.dp + assertNear("the first line starts under the status bar and the top padding", frame.top + 24.dp + gap, bounds("line-0").top) + assertTrue("the block is taller than the room: the last line starts under the keyboard's top", bounds("line-9").top > frame.bottom - 300.dp) + rule.onNode(hasScrollAction()).performSemanticsAction(SemanticsActions.ScrollBy) { it(0f, 100_000f) } + rule.waitForIdle() + assertNear("scrolled to the end, the last line stands on the bottom padding above the keyboard", frame.bottom - 300.dp - gap, bounds("line-9").bottom) + } + + @Test + fun thePiecesOfTheOneBlockAreABlockGapApart() { + centered({ wide }) { + Text("하나", Modifier.testTag("first")) + Text("둘", Modifier.testTag("second")) + Text("셋", Modifier.testTag("third")) + } + assertNear("the second piece is a block gap under the first", bounds("first").bottom + DdsScreen.blockGap.dp, bounds("second").top) + assertNear("the third a block gap under the second", bounds("second").bottom + DdsScreen.blockGap.dp, bounds("third").top) + val block = bounds(DdsTestTags.Centered) + assertNear("no gap above the first piece", block.top, bounds("first").top) + assertNear("none under the last", block.bottom, bounds("third").bottom) + } + + @Test + fun inAScaffoldUnderATopBarTheBlockIsCenteredInTheRoomTheBarsLeave() { + val top = 24.dp + val bottom = 48.dp + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(phone)) { + val insets = with(LocalDensity.current) { + WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.statusBars(), Insets.of(0, top.roundToPx(), 0, 0)) + .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.of(0, 0, 0, bottom.roundToPx())) + .build() + } + DeviceConfigurationOverride(DeviceConfigurationOverride.WindowInsets(insets)) { + DdsTheme { + DdsAppFrame(four, 0, {}, Modifier.testTag("frame"), navigation = false) { + DdsScaffold(topBar = { DdsTopBar("로그인", Modifier.testTag("bar")) }) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + DdsCenteredColumn { Text("한 가지 일", Modifier.fillMaxWidth()) } + } + } + } + } + } + } + } + val frame = bounds("frame") + val bar = bounds("bar") + val area = bounds(DdsTestTags.CenteredArea) + val block = bounds(DdsTestTags.Centered) + assertTrue("the bar is under the status bar: $bar", bar.bottom > frame.top + top) + // The scaffold already keeps its content clear of the status bar (the top bar's height holds it) and of the + // navigation bar: the block takes neither a second time. + assertNear("the room starts under the bar, not a status bar lower", bar.bottom, area.top) + assertNear("and ends above the navigation bar, not a navigation bar higher", frame.bottom - bottom, area.bottom) + assertEquals("the block is in the middle of that room", ((bar.bottom + frame.bottom - bottom) / 2).value, ((block.top + block.bottom) / 2).value, 1f) + } + + @Test + fun theOneBlockSlidesWhenTheScreenArrives() { + assumeFalse("animations are turned off on this device", isReducedMotion(InstrumentationRegistry.getInstrumentation().targetContext)) + var screen by mutableStateOf("a") + var move by mutableStateOf(DdsMove.None) + rule.setContent { + DdsTheme { + DdsScreenHost(screen, move) { shown -> + DdsCenteredColumn { Text("한 가지 일 $shown", Modifier.testTag("text-$shown")) } + } + } + } + fun left(shown: String) = rule.onNodeWithTag("text-$shown", useUnmergedTree = true).fetchSemanticsNode().boundsInRoot.left + rule.mainClock.autoAdvance = false + move = DdsMove.Push + screen = "b" + rule.mainClock.advanceTimeByFrame() + rule.mainClock.advanceTimeByFrame() + val arriving = left("b") + rule.mainClock.autoAdvance = true + rule.waitForIdle() + val settled = left("b") + assertTrue("the block starts to the trailing side of where it settles: $arriving vs $settled", arriving > settled + 1f) + } + + // ---- the list's rows beside the rail's ---- + + @Test + fun aListItemThatIsNotChosenIsInTheSecondaryTextColor() { + val words = "매출 현황" + rule.setContent { + DdsTheme(darkTheme = false) { + Column(Modifier.background(DdsTheme.colors.bgDefault)) { + DdsPaneList { DdsPaneItem(words, selected = false, onClick = {}, modifier = Modifier.testTag("other")) } + // The same words in the two inks it could be, to compare what reaches the screen. + Text(words, Modifier.testTag("secondary"), color = DdsTheme.colors.textSecondary, style = DdsTheme.typography.body1) + Text(words, Modifier.testTag("primary"), color = DdsTheme.colors.textPrimary, style = DdsTheme.typography.body1) + } + } + } + /** The darkest point of the words: the ink where a stroke covers a whole pixel. */ + fun darkest(tag: String): Float { + val pixels = rule.onNodeWithTag(tag).captureToImage().toPixelMap() + var least = 3f + for (y in 0 until pixels.height) for (x in 0 until pixels.width) pixels[x, y].let { least = minOf(least, it.red + it.green + it.blue) } + return least + } + val secondary = darkest("secondary") + val primary = darkest("primary") + assertTrue("the two inks can be told apart on screen: $primary and $secondary", secondary > primary + 0.3f) + assertEquals("a row that is not chosen is written in the secondary ink, as the rail's and the tabs' are", secondary, darkest("other"), 0.06f) + } + + @Test + fun choosingAListItemWhoseLabelOnlyJustFitsDoesNotMakeItTaller() { + val label = "Sales and receivables overview" + var paneWidth by mutableStateOf(400.dp) + rule.setContent { + DdsTheme { + Column { + Text(label, Modifier.testTag("normal"), style = DdsTheme.typography.body1, softWrap = false, maxLines = 1) + Text(label, Modifier.testTag("heavy"), style = DdsTheme.typography.body1.copy(fontWeight = FontWeight.SemiBold), softWrap = false, maxLines = 1) + Box(Modifier.width(paneWidth)) { + DdsPaneList { + DdsPaneItem(label, selected = false, onClick = {}, modifier = Modifier.testTag("other")) + DdsPaneItem(label, selected = true, onClick = {}, modifier = Modifier.testTag("chosen")) + DdsPaneItem("Sales", selected = false, onClick = {}, modifier = Modifier.testTag("one-line")) + } + } + } + } + } + fun width(tag: String) = bounds(tag).let { it.right - it.left } + val normal = width("normal") + val heavy = width("heavy") + assertTrue("the heavier label is wider: $normal against $heavy", heavy > normal + 2.dp) + // A row's label has the list's width less the gutter at each side. Give it room for the label + // at its normal weight and not at the heavier one. + val gutter = bounds("other").left + DdsSpace.s12.dp - bounds("normal").left + paneWidth = (normal + heavy) / 2 + gutter * 2 + rule.waitForIdle() + assertTrue("the chosen row needs a second line: ${height("chosen")} against ${height("one-line")}", height("chosen") > height("one-line") + 12.dp) + assertNear("the row that is not chosen is already as tall, so choosing it moves nothing", height("chosen"), height("other")) + } +} diff --git a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/MotionOnDeviceTest.kt b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/MotionOnDeviceTest.kt index 0ed3dc33..cb871d21 100644 --- a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/MotionOnDeviceTest.kt +++ b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/MotionOnDeviceTest.kt @@ -35,6 +35,19 @@ import kr.devslab.dds.compose.motion.DdsScreenHost import kr.devslab.dds.compose.motion.LocalDdsScreenActive import kr.devslab.dds.compose.motion.ddsScreenContent import kr.devslab.dds.compose.theme.DdsTheme +import androidx.compose.ui.test.DeviceConfigurationOverride +import androidx.compose.ui.test.ForcedSize +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.unit.DpSize +import kotlin.math.abs +import kr.devslab.dds.compose.component.DdsNavItem +import kr.devslab.dds.compose.component.DdsScaffold +import kr.devslab.dds.compose.component.DdsTopBar +import kr.devslab.dds.compose.icons.DdsIcons +import kr.devslab.dds.compose.layout.DdsAppFrame +import kr.devslab.dds.compose.layout.DdsBodyColumn +import kr.devslab.dds.compose.layout.DdsTestTags import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals @@ -322,4 +335,136 @@ class MotionOnDeviceTest { assertNotEquals("part-way at 50ms, not still unchosen", before, midway) assertFalse("part-way at 50ms, not already chosen", midway == after) } + + // ---- the app frame through a change of screen ---- + + private val twoTabs = listOf("홈", "거래처").map { DdsNavItem(it, DdsIcons.Doc) } + + /** + * An app frame (tagged `frame`) in a window forced to [size], around a host whose screens are a top bar + * over a plain color (tagged `fill a`, `fill b`). Screen "a" is a top-level one: it asks for the bottom tabs. + */ + private fun framedHost(size: DpSize, screen: () -> String, move: () -> DdsMove) { + rule.setContent { + DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(size)) { + DdsTheme { + DdsAppFrame(twoTabs, 0, {}, Modifier.testTag("frame")) { + DdsScreenHost(screen(), move()) { shown -> + DdsScaffold(topBar = { DdsTopBar("화면 $shown") }, bottomTabs = shown == "a") { padding -> + Box(Modifier.fillMaxSize().padding(padding).background(if (shown == "a") red else blue).testTag("fill $shown")) { + DdsBodyColumn { Text("word $shown") } + } + } + } + } + } + } + } + } + + /** Where the node tagged [tag] is, in pixels of the root. */ + private fun place(tag: String) = rule.onNodeWithTag(tag, useUnmergedTree = true).fetchSemanticsNode().boundsInRoot + + /** + * [now] is [base] with [over] part-way over it, or one of the two: nothing else shows through. + * Returns how far over it is, from 0 to 1. + */ + private fun assertIsOneOverTheOther(message: String, base: Color, over: Color, now: Color): Float { + val step = floatArrayOf(over.red - base.red, over.green - base.green, over.blue - base.blue) + val moved = floatArrayOf(now.red - base.red, now.green - base.green, now.blue - base.blue) + // How far along, read from the channel that changes most. + val widest = step.indices.maxByOrNull { abs(step[it]) }!! + val along = if (abs(step[widest]) < 0.02f) 0f else moved[widest] / step[widest] + assertTrue("$message: $now is not between $base and $over", along in -0.04f..1.04f) + for (channel in step.indices) { + assertEquals("$message: $now is not $base with $over over it", along * step[channel], moved[channel], 0.05f) + } + return along + } + + @Test + fun theRailDoesNotChangeWhileTheScreenBesideItChanges() { + var screen by mutableStateOf("a") + var move by mutableStateOf(DdsMove.None) + framedHost(DpSize(933.dp, 704.dp), { screen }, { move }) + fun rail() = rule.onNodeWithTag(DdsTestTags.Rail) + fun fill(): Color { + val pixels = rule.onNodeWithTag("frame").captureToImage().toPixelMap() + // Three quarters across and down: the screen's color, clear of the rail, the top bar and the words. + return pixels[pixels.width * 3 / 4, pixels.height * 3 / 4] + } + val before = rail().fetchSemanticsNode() + val pixelsBefore = rail().captureToImage().toPixelMap() + assertEquals(red, fill()) + + rule.mainClock.autoAdvance = false + move = DdsMove.Push + screen = "b" + var mixed = 0 + repeat(12) { frame -> + rule.mainClock.advanceTimeByFrame() + val now = rail().fetchSemanticsNode() + // The same rail, not another one drawn by the screen that is arriving. + assertEquals("frame $frame: the rail is the one that was there before the change", before.id, now.id) + assertEquals("frame $frame: the rail has moved", before.boundsInRoot, now.boundsInRoot) + val pixels = rail().captureToImage().toPixelMap() + assertEquals("frame $frame: the rail's size in pixels", pixelsBefore.width to pixelsBefore.height, pixels.width to pixels.height) + val changed = pixelsBefore.buffer.indices.count { pixelsBefore.buffer[it] != pixels.buffer[it] } + assertEquals("frame $frame: pixels of the rail that changed", 0, changed) + val along = assertIsOneOverTheOther("frame $frame beside the rail", red, blue, fill()) + if (along > 0.1f && along < 0.9f) mixed++ + } + assertTrue("the screen beside the rail was part-way through its change for several frames, was for $mixed", mixed >= 2) + rule.mainClock.autoAdvance = true + rule.waitForIdle() + assertEquals(blue, fill()) + assertEquals("after the change it is still the same rail", before.id, rail().fetchSemanticsNode().id) + } + + @Test + fun openingAScreenOverATabCoversTheBottomTabsWithNoFrameEmpty() { + var screen by mutableStateOf("a") + var move by mutableStateOf(DdsMove.None) + framedHost(DpSize(475.dp, 751.dp), { screen }, { move }) + rule.onNodeWithTag(DdsTestTags.BottomTabs).assertIsDisplayed() + val frame = place("frame") + val tabs = place(DdsTestTags.BottomTabs) + val content = place("fill a") + assertEquals("the tab screen's color ends where the tabs begin", tabs.top, content.bottom, 1f) + // One line of pixels down the screen's start edge, clear of the words and of the tabs' icons: from under + // the top bar, down the tab screen's color, across the tabs' top line and 40dp into the tab row. + val x = 4 + val top = (content.top - frame.top).toInt() + 2 + val tabsTop = (tabs.top - frame.top).toInt() + val bottom = tabsTop + (40 * frame.width / 475f).toInt() // 40dp of this forced window + fun line(): List { + val pixels = rule.onNodeWithTag("frame").captureToImage().toPixelMap() + return (top until bottom).map { pixels[x, it] } + } + val before = line() + assertEquals("the line starts in the tab screen's color", red, before.first()) + assertTrue("the line ends in the tab row, which is not the tab screen's color", before.last() != red) + + rule.mainClock.autoAdvance = false + move = DdsMove.Push + screen = "b" + var mixed = 0 + repeat(12) { step -> + rule.mainClock.advanceTimeByFrame() + val now = line() + var partWay = false + for (y in now.indices) { + val what = if (top + y < tabsTop) "the tab screen" else "the bottom tabs" + val along = assertIsOneOverTheOther("frame $step, ${top + y}px down, over $what", before[y], blue, now[y]) + if (along > 0.1f && along < 0.9f) partWay = true + } + if (partWay) mixed++ + } + assertTrue("the new screen was part-way over the tabs for several frames, was for $mixed", mixed >= 2) + rule.mainClock.autoAdvance = true + rule.waitForIdle() + val after = line() + for (y in after.indices) assertEquals("at the end, ${top + y}px down is the new screen", blue, after[y]) + rule.onAllNodesWithTag(DdsTestTags.BottomTabs).assertCountEquals(0) + } } diff --git a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/NavDisplayOnDeviceTest.kt b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/NavDisplayOnDeviceTest.kt index 6c6c4363..415fc7c8 100644 --- a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/NavDisplayOnDeviceTest.kt +++ b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/NavDisplayOnDeviceTest.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import kr.devslab.dds.compose.layout.DdsCenteredColumn import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -181,4 +183,54 @@ class NavDisplayOnDeviceTest { val host = rule.onNodeWithTag("host").fetchSemanticsNode().boundsInRoot assertTrue("forward starts to the trailing side of where it ends", body.left > host.left) } + + @Test + fun aCenteredScreenCoversTheScreenUnderItWithThePageAllTheWay() { + var page = Color.Unspecified + rule.setContent { + DdsTheme { + page = DdsTheme.colors.bgDefault + // Green stands for the window: it shows only where no screen covers it. A NavDisplay paints + // nothing behind its entries, so a centered screen must cover the one under it by itself. + Box(Modifier.size(200.dp).background(Color.Green).testTag("host")) { + NavDisplay( + backStack = backStack, + onBack = { }, + transitionSpec = { ddsScreenChange(move, reduced = false) }, + popTransitionSpec = { ddsScreenChange(DdsMove.Pop, reduced = false) }, + predictivePopTransitionSpec = { ddsScreenChange(DdsMove.Pop, reduced = false) }, + entryProvider = { key -> + NavEntry(key) { + DdsScreenMotion(move) { + if (key == "a") { + Box(Modifier.fillMaxSize().background(red)) + } else { + DdsCenteredColumn { Text("word $key") } + } + } + } + }, + ) + } + } + } + // A point at the start of the top edge, clear of the block in the middle, and inside the 24dp the + // content slides across: what is there while the block is still on its way is the page, not the window. + fun corner(): Color { + val pixels = rule.onNodeWithTag("host").captureToImage().toPixelMap() + return pixels[4, 4] + } + assertEquals(red, corner()) + + rule.mainClock.autoAdvance = false + forward("b") + repeat(24) { frame -> // 24 frames is longer than the 300ms the slide takes + rule.mainClock.advanceTimeByFrame() + val now = corner() + assertEquals("frame $frame: the window shows through at the corner of the centered screen ($now)", 0f, now.green - minOf(now.red, now.blue), 0.06f) + } + rule.mainClock.autoAdvance = true + rule.waitForIdle() + assertEquals("at the end the centered screen shows the page where the red screen was", page, corner()) + } } diff --git a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/ScreenReaderTest.kt b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/ScreenReaderTest.kt index 3a84dcd6..955bb0d9 100644 --- a/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/ScreenReaderTest.kt +++ b/android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/ScreenReaderTest.kt @@ -58,6 +58,11 @@ import kr.devslab.dds.compose.component.DdsMenuAlign import kr.devslab.dds.compose.component.DdsMenuDivider import kr.devslab.dds.compose.component.DdsMenuItem import kr.devslab.dds.compose.component.DdsNotice +import kr.devslab.dds.compose.component.DdsPaneItem +import kr.devslab.dds.compose.component.DdsPaneList +import kr.devslab.dds.compose.layout.DdsBodyColumn +import kr.devslab.dds.compose.layout.DdsCenteredColumn +import kr.devslab.dds.compose.layout.DdsCenteredTitle import kr.devslab.dds.compose.component.DdsSearchField import kr.devslab.dds.compose.component.DdsSelectField import kr.devslab.dds.compose.component.DdsTabs @@ -368,10 +373,71 @@ class ScreenReaderTest { rule.onNodeWithContentDescription(word(R.string.dds_a11y_sheet_handle)).assertExists() } + @Test + fun theBackButtonHasAName() { + show { kr.devslab.dds.compose.component.DdsTopBar("거래처", onBack = {}) } + val name = word(R.string.dds_a11y_back) + assertTrue("the back button's name is not empty", name.isNotBlank()) + rule.onNodeWithContentDescription(name).assertExists() + } + @Test fun aScreenCanSayItsName() { show { Box(Modifier.ddsScreenContent(paneTitle = "설정")) { Text("내용") } } rule.onNode(expectValue(SemanticsProperties.PaneTitle, "설정")).assertExists() } + @Test + fun aBodyColumnSaysTheNameOfItsScreen() { + show { DdsBodyColumn(paneTitle = "거래처") { Text("내용") } } + rule.onNode(expectValue(SemanticsProperties.PaneTitle, "거래처")).assertExists() + } + + @Test + fun aCenteredColumnSaysTheNameOfItsScreen() { + show { DdsCenteredColumn(paneTitle = "로그인") { Text("내용") } } + rule.onNode(expectValue(SemanticsProperties.PaneTitle, "로그인")).assertExists() + } + + @Test + fun aColumnGivenNoNameGivesTheScreenNone() { + show { + Column { + DdsBodyColumn(Modifier.height(100.dp)) { Text("내용") } + DdsCenteredColumn(Modifier.height(100.dp)) { Text("가운데") } + } + } + rule.onAllNodes(keyIsDefined(SemanticsProperties.PaneTitle)).assertCountEquals(0) + } + + // --- the screen frame ---------------------------------------------------------- + + @Test + fun theCenteredTitleIsOneHeading() { + show { DdsCenteredTitle("정산을\n시작합니다", description = "사업자 번호로 로그인하세요") } + // One stop: the title as written, line break and all, and its sentence with it. + rule.onAllNodes(keyIsDefined(SemanticsProperties.Heading)).assertCountEquals(1) + rule.onNode(keyIsDefined(SemanticsProperties.Heading)) + .assert(hasText("정산을\n시작합니다")) + .assert(hasText("사업자 번호로 로그인하세요")) + rule.onAllNodes(hasText("사업자 번호로 로그인하세요")).assertCountEquals(1) + } + + @Test + fun aListItemSaysItIsChosenAndWhereItIs() { + val names = listOf("기본 정보", "매출 현황", "미수금", "거래처", "품목", "세금계산서", "설정") + show { + DdsPaneList { + names.forEachIndexed { index, name -> DdsPaneItem(name, selected = index == 1, onClick = {}, enabled = index != 6) } + } + } + // A group of things to choose from is what makes a screen reader say "2 of 7"; each row says whether it is the one chosen. + val rows = rule.onNode(keyIsDefined(SemanticsProperties.SelectableGroup)).onChildren() + rows.assertCountEquals(7) + rows.assertAll(expectValue(SemanticsProperties.Role, Role.Tab)) + rows[1].assert(hasText("매출 현황")).assert(expectValue(SemanticsProperties.Selected, true)) + rows[0].assert(hasText("기본 정보")).assert(expectValue(SemanticsProperties.Selected, false)) + rows[6].assert(hasText("설정")).assert(keyIsDefined(SemanticsProperties.Disabled)) + } + } diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Pane.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Pane.kt new file mode 100644 index 00000000..678fce9a --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Pane.kt @@ -0,0 +1,109 @@ +package kr.devslab.dds.compose.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.layout.ddsFrame +import kr.devslab.dds.compose.layout.gutter +import kr.devslab.dds.compose.motion.clear +import kr.devslab.dds.compose.motion.colorChange +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsControl +import kr.devslab.dds.tokens.DdsSpace +import kr.devslab.dds.tokens.DdsTouch + +/** + * The list an area of the app is moved around by: the pinned list beside the content on a wide + * window ([kr.devslab.dds.compose.layout.DdsAppFrame]'s `list`), and the same list as a screen of + * its own on a narrower one. Its rows are [DdsPaneItem]s. + * + * It scrolls when the rows do not fit. The rows are set in from the sides so that their labels + * start on the window's gutter line, the line a top bar's title and a body's text start on. A + * screen reader is told the rows are one group to choose from, so it says which one is chosen and + * where it is ("2 of 7"). + * + * @param content the rows, top to bottom + */ +@Composable +public fun DdsPaneList(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + // A row's label is PaneItemInset in from the row's edge; the rest of the gutter is outside the row. + val side = ddsFrame().width.gutter - PaneItemInset.dp + Column( + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .selectableGroup() + .padding(horizontal = side, vertical = DdsSpace.s8.dp), + verticalArrangement = Arrangement.spacedBy(DdsSpace.s4.dp), + content = content, + ) +} + +/** + * One row of a [DdsPaneList]: a place in this area of the app. + * + * The chosen row looks like the chosen destination of the rail: the pale brand fill, the brand + * color, and a heavier label, never the color alone. A row that is not chosen is written in the + * secondary text color, as the rail's and the bottom tabs' other destinations are. The colors ease in. The row is as wide as the + * list lets it be and at least [DdsControl.md] high ([DdsTouch.large] in large text mode); a label + * too long for one line wraps and the row grows. Being chosen changes neither the width nor the + * height. + * + * @param label the name of the place, in the app's words + * @param selected true for the row whose screen is showing + * @param onClick called when the row is picked + * @param modifier applied to the row, which is as wide as the list lets it be + * @param enabled false dims the row and it cannot be picked + */ +@Composable +public fun DdsPaneItem(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) { + val colors = DdsTheme.colors + val interaction = remember { MutableInteractionSource() } + val shape = MaterialTheme.shapes.small + val fill by colorChange(if (selected) colors.bgBrandSubtle else colors.bgBrandSubtle.clear()) + val ink by colorChange(if (selected) colors.textBrand else colors.textSecondary) + val minHeight = if (DdsTheme.largeText) DdsTouch.large else DdsControl.md + val style = DdsTheme.typography.body1 + Box( + modifier + .fillMaxWidth() + .heightIn(min = minHeight.dp) + .alpha(if (enabled) 1f else DISABLED_ALPHA) + .clip(shape) + .background(fill) + .ddsFocusRing(interaction, shape) + .selectable(selected, interaction, ripple(), enabled, Role.Tab, onClick) + .padding(horizontal = PaneItemInset.dp, vertical = DdsSpace.s8.dp), + contentAlignment = Alignment.CenterStart, + ) { + // An unseen copy at the heavier weight holds the room, so choosing the row cannot add a line to it. + WordsText(label, Modifier.alpha(0f).clearAndSetSemantics { }, style = style.copy(fontWeight = FontWeight.SemiBold)) + WordsText(label, style = style.copy(fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal), color = ink) + } +} + +/** How far a row's label is in from the row's own edge. */ +private const val PaneItemInset: Int = DdsSpace.s12 diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Shell.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Shell.kt index 30b53dbb..4b9c629a 100644 --- a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Shell.kt +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Shell.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope @@ -26,6 +27,7 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailDefaults import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItemDefaults import androidx.compose.material3.Scaffold @@ -36,12 +38,31 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.Constraints +import kr.devslab.dds.compose.layout.BarTitleStart +import kr.devslab.dds.compose.layout.barMinHeight +import androidx.compose.ui.res.stringResource +import kr.devslab.dds.compose.icons.DdsIcons +import kr.devslab.dds.tokens.DdsIcon as DdsIconSize +import kr.devslab.dds.tokens.DdsTouch +import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontWeight +import kr.devslab.dds.compose.R +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kr.devslab.dds.compose.icons.DdsIcon +import kr.devslab.dds.compose.layout.DdsTestTags +import kr.devslab.dds.compose.layout.DdsWindowWidth +import kr.devslab.dds.compose.layout.LocalFrameNav +import kr.devslab.dds.compose.layout.ddsFrame +import kr.devslab.dds.compose.layout.gutter import kr.devslab.dds.compose.theme.DdsTheme import kr.devslab.dds.tokens.DdsBar import kr.devslab.dds.tokens.DdsBorder @@ -55,45 +76,106 @@ import kr.devslab.dds.tokens.DdsSpace * screens, a bar that is the same on both shows no change, and a title that * differs fades to the new one. * - * @param leading drawn before the title: the app mark on a top-level screen, - * or a back [DdsIconButton] on a deeper one - * @param actions drawn at the end (icon buttons) + * The content sits on the same lines as the body column ([kr.devslab.dds.compose.layout.DdsBodyColumn]): + * it is centered in the area, no wider than [columnMax], and keeps the window's gutter from each + * edge. The back arrow's glyph starts on the text line and the last icon's glyph ends on the end + * line, so their 48dp touch areas reach 12dp into the gutter. The title starts 40dp after the + * text line whether an arrow or a mark comes before it, so it does not move when a screen opens a + * detail; with neither it starts on the line itself. The hairline and the background span the + * whole area. + * + * When the frame says the bar is tall ([kr.devslab.dds.compose.layout.DdsFrame.tallBar]) the bar is + * [DdsBar.topTall] high and the title takes two lines; otherwise it is [DdsBar.top] high on one. + * + * @param leading drawn before the title, at least 28dp wide: the app mark on a top-level screen. + * A wider one (a button) still works; the title moves over. Not drawn when [onBack] is set. + * @param onBack goes back; when set, the bar draws the back arrow itself, aligned to the lines + * @param backEnabled false greys the arrow out while going back is not allowed + * @param backDescription what a screen reader says for the arrow + * @param columnMax the widest the content gets; pass the same value the screen's body column has + * @param actions drawn at the end: icon buttons. A text button at the end needs the app to leave + * 12dp after it, since only an icon button's glyph is inset from its touch area. */ @Composable public fun DdsTopBar( title: String, modifier: Modifier = Modifier, leading: (@Composable () -> Unit)? = null, + onBack: (() -> Unit)? = null, + backEnabled: Boolean = true, + backDescription: String = stringResource(R.string.dds_a11y_back), + columnMax: Dp = DdsScreen.bodyMax.dp, actions: @Composable RowScope.() -> Unit = {}, ) { val colors = DdsTheme.colors + val frame = ddsFrame() + val gutter = frame.width.gutter + val tall = frame.tallBar + val minHeight = barMinHeight(tall) Column(modifier.fillMaxWidth().background(colors.bgDefault)) { - Row( - Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.statusBars) - .heightIn(min = DdsBar.top.dp) - .padding(horizontal = DdsScreen.gutter.dp), - horizontalArrangement = Arrangement.spacedBy(DdsSpace.s12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { + Box(Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.statusBars), contentAlignment = Alignment.TopCenter) { CompositionLocalProvider(LocalContentColor provides colors.textPrimary) { - leading?.invoke() - Text( - title, - Modifier.weight(1f).semantics { heading() }, - style = DdsTheme.typography.title2, - color = colors.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - actions() + // One layout places the arrow or mark, the title and the actions from the same pixel + // lines, so where the title starts does not depend on what stands before it. + Layout( + content = { + if (onBack != null) { + DdsIconButton(DdsIcons.ArrowLeft, backDescription, onBack, Modifier.layoutId(BarSlot.Arrow), enabled = backEnabled) + } else if (leading != null) { + Box(Modifier.layoutId(BarSlot.Mark)) { leading() } + } + Text( + title, + Modifier.layoutId(BarSlot.Title).testTag(DdsTestTags.TopBarTitle).semantics { heading() }, + style = DdsTheme.typography.title2, + color = colors.textPrimary, + maxLines = if (tall) 2 else 1, + overflow = TextOverflow.Ellipsis, + ) + Row(Modifier.layoutId(BarSlot.Actions), verticalAlignment = Alignment.CenterVertically, content = actions) + }, + modifier = Modifier.widthIn(max = columnMax).fillMaxWidth(), + ) { measurables, constraints -> + val width = constraints.maxWidth + val textLine = gutter.roundToPx() + val endLine = width - textLine + // A 48dp button holds a 24dp glyph in its middle. The arrow is placed so the glyph's start + // edge is on the text line, the last icon so the glyph's end edge is on the end line; + // the touch areas reach `hang` past the glyph into the gutter. + val buttonPx = DdsTouch.min.dp.roundToPx() + val glyphPx = DdsIconSize.md.dp.roundToPx() + val hangStart = (buttonPx - glyphPx + 1) / 2 + val hangEnd = buttonPx - glyphPx - hangStart + val loose = Constraints(maxWidth = width, maxHeight = constraints.maxHeight) + fun measure(slot: BarSlot, limit: Constraints = loose) = measurables.firstOrNull { it.layoutId == slot }?.measure(limit) + val arrow = measure(BarSlot.Arrow) + val mark = measure(BarSlot.Mark) + val actionsPlaced = measure(BarSlot.Actions, loose.copy(maxWidth = (endLine + hangEnd).coerceAtLeast(0))) + val actionsLeft = if (actionsPlaced != null && actionsPlaced.width > 0) endLine + hangEnd - actionsPlaced.width else endLine + // The title starts at one place after an arrow or a mark; a mark wider than its room pushes it. + val titleStart = when { + arrow != null -> textLine + BarTitleStart.roundToPx() + mark != null -> textLine + maxOf(BarTitleStart.roundToPx(), mark.width + DdsSpace.s12.dp.roundToPx()) + else -> textLine + } + val titlePlaced = measurables.first { it.layoutId == BarSlot.Title } + .measure(Constraints(maxWidth = (actionsLeft - titleStart).coerceAtLeast(0), maxHeight = constraints.maxHeight)) + val height = maxOf(minHeight.roundToPx(), titlePlaced.height, arrow?.height ?: 0, mark?.height ?: 0, actionsPlaced?.height ?: 0) + layout(width, height) { + arrow?.placeRelative(textLine - hangStart, (height - arrow.height) / 2) + mark?.placeRelative(textLine, (height - mark.height) / 2) + titlePlaced.placeRelative(titleStart, (height - titlePlaced.height) / 2) + actionsPlaced?.placeRelative(actionsLeft, (height - actionsPlaced.height) / 2) + } + } } } DdsDivider() } } +private enum class BarSlot { Arrow, Mark, Title, Actions } + /** One destination of the bottom tabs or the rail. The label is the app's. */ @Immutable public class DdsNavItem(public val label: String, public val icon: ImageVector) @@ -103,7 +185,8 @@ public class DdsNavItem(public val label: String, public val icon: ImageVector) * * The selected tab changes three things at once — the pale brand pill behind * the icon, the brand color, and a heavier label — never the color alone. A - * label stays on one line and is cut with an ellipsis at large font sizes. + * label takes up to two lines, centered, and the bar grows to hold them at + * large font sizes; a longer label is cut with an ellipsis after the second line. */ @Composable public fun DdsBottomBar( @@ -113,7 +196,7 @@ public fun DdsBottomBar( modifier: Modifier = Modifier, ) { val colors = DdsTheme.colors - Column(modifier) { + Column(modifier.testTag(DdsTestTags.BottomTabs)) { DdsDivider() NavigationBar(containerColor = colors.bgDefault, tonalElevation = 0.dp) { items.forEachIndexed { index, item -> @@ -136,7 +219,11 @@ public fun DdsBottomBar( } } -/** The same destinations as a rail at the start edge, for a window 600dp wide or more. */ +/** + * The same destinations as a rail at the start edge, for a window 600dp wide or more. + * The rail is [DdsBar.rail] wide whatever the font size; a label takes up to two + * lines, and the items scroll when they are taller than the rail. + */ @Composable public fun DdsNavRail( items: List, @@ -146,22 +233,37 @@ public fun DdsNavRail( ) { val colors = DdsTheme.colors Row(modifier.fillMaxHeight()) { - NavigationRail(containerColor = colors.bgDefault) { - items.forEachIndexed { index, item -> - val selected = index == selectedIndex - NavigationRailItem( - selected = selected, - onClick = { onSelect(index) }, - icon = { DdsIcon(item.icon, contentDescription = null) }, - label = { NavLabel(item.label, selected) }, - colors = NavigationRailItemDefaults.colors( - selectedIconColor = colors.textBrand, - selectedTextColor = colors.textBrand, - indicatorColor = colors.bgBrandSubtle, - unselectedIconColor = colors.textSecondary, - unselectedTextColor = colors.textSecondary, - ), - ) + // The system insets (a display cutout at the start, the bars above and below) go outside the + // rail, so the rail itself is always its full width and its items keep all of it. + Row(Modifier.fillMaxHeight().windowInsetsPadding(NavigationRailDefaults.windowInsets)) { + NavigationRail( + Modifier.testTag(DdsTestTags.Rail).width(DdsBar.rail.dp), + containerColor = colors.bgDefault, + windowInsets = WindowInsets(0, 0, 0, 0), + ) { + // The items scroll when there are more than the rail is tall: at large text each one is taller. + Column( + Modifier.weight(1f).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(DdsSpace.s4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items.forEachIndexed { index, item -> + val selected = index == selectedIndex + NavigationRailItem( + selected = selected, + onClick = { onSelect(index) }, + icon = { DdsIcon(item.icon, contentDescription = null) }, + label = { NavLabel(item.label, selected) }, + colors = NavigationRailItemDefaults.colors( + selectedIconColor = colors.textBrand, + selectedTextColor = colors.textBrand, + indicatorColor = colors.bgBrandSubtle, + unselectedIconColor = colors.textSecondary, + unselectedTextColor = colors.textSecondary, + ), + ) + } + } } } Box(Modifier.fillMaxHeight().width(DdsBorder.hairline.dp).background(colors.borderDefault)) @@ -173,8 +275,9 @@ private fun NavLabel(text: String, selected: Boolean) { Text( text, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - maxLines = 1, - softWrap = false, + textAlign = TextAlign.Center, + maxLines = 2, + softWrap = true, overflow = TextOverflow.Ellipsis, ) } @@ -217,6 +320,47 @@ public fun DdsScaffold( } } +/** + * One screen of an app that sits inside [kr.devslab.dds.compose.layout.DdsAppFrame]: its top + * bar, and when [bottomTabs] is set, the bottom tabs. + * + * The tabs are drawn here, inside the screen, so that a screen sliding in over this one covers + * the tab row with it. They are drawn only on a narrow window (under 600dp), where the frame has + * no rail, and only when a frame supplied the destinations. Set [bottomTabs] on top-level + * screens and leave it off on detail screens. + * + * The frame keeps the screen clear of a display cutout at the sides and marks those insets as + * used, so this does not add them again. + * + * The system insets the given padding already covers are marked used for [content]: the status + * bar (the top bar's height holds it) and the navigation bar (the bottom tabs' height, or the + * padding's bottom). So something inside that pads for insets, such as + * [kr.devslab.dds.compose.layout.DdsCenteredColumn] or an `imePadding()`, does not pad them a + * second time; the keyboard's height beyond the navigation bar is still left to it. + * + * @param content the body of the screen; apply the given padding to it + */ +@Composable +public fun DdsScaffold( + topBar: @Composable () -> Unit, + modifier: Modifier = Modifier, + bottomTabs: Boolean = false, + content: @Composable (PaddingValues) -> Unit, +) { + val nav = LocalFrameNav.current + val showTabs = bottomTabs && nav != null && ddsFrame().width == DdsWindowWidth.Compact + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = topBar, + bottomBar = { if (showTabs && nav != null) DdsBottomBar(nav.items, nav.selectedIndex, nav.onSelect) }, + containerColor = DdsTheme.colors.bgDefault, + contentColor = DdsTheme.colors.textPrimary, + ) { padding -> + // What the padding keeps the content clear of is marked used, so it is not kept clear of twice. + Box(Modifier.consumeWindowInsets(padding)) { content(padding) } + } +} + /** * A dialog that asks for a decision. Use it to confirm; offer choices and * actions in a bottom sheet instead. diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/AppFrame.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/AppFrame.kt new file mode 100644 index 00000000..6c8973b5 --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/AppFrame.kt @@ -0,0 +1,129 @@ +package kr.devslab.dds.compose.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.union +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.component.DdsNavItem +import kr.devslab.dds.compose.component.DdsNavRail +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsBar +import kr.devslab.dds.tokens.DdsBorder +import kr.devslab.dds.tokens.DdsScreen + +/** + * The frame of an app: it sits once, outside the screen switcher, and draws what must not move + * when a screen changes. + * + * What it draws depends on the width of the window it is given: + * - narrow (under 600dp): nothing. Each top-level screen draws the bottom tabs itself, with + * [kr.devslab.dds.compose.component.DdsScaffold] and `bottomTabs = true`, so a detail screen + * can cover the tab row while it slides in. + * - medium (600dp up): the navigation rail at the start edge. + * - wide (840dp up): the rail, and when [list] is given, that list beside it, [DdsScreen.listPane] + * wide and followed by a hairline. + * + * [content] is the screen switcher. It is called from one fixed place, so what the screens hold + * (saved state, scroll positions) survives the rail and the list appearing and disappearing when + * the window changes width. Screens read what the frame drew with [ddsFrame]. + * + * The frame keeps the screens clear of a display cutout at the sides: the rail takes the start + * side, the content area the end side (both, when there is no rail and no list). The top and the + * bottom are the screens' own: the top bar and the bottom tabs handle them. + * + * @param navItems the destinations, shown as the rail or as the screens' bottom tabs + * @param selectedIndex the selected destination + * @param onSelect called with the index of the destination the user picks + * @param navigation false for a screen that has no menu at all (sign-in, a single-task screen): + * nothing is drawn at any width and the content gets the whole window + * @param barTitles the titles the top bars of the app can show. When any of them does not fit on + * one line at the current text size and width, every top bar in the window becomes the tall + * two-line bar ([DdsFrame.tallBar]), so the body starts at the same height on every screen. Empty + * (the default) keeps the short bar with a title cut to one line. + * @param barActions how many icon buttons a top bar has at its end, at most + * @param barReserve the width a screen's top bar keeps free for something of its own next to the + * title, for a given title (a step counter, for example) + * @param list the list that belongs to the area the current screen is in, or null when there is + * none; it is pinned beside the content only on a wide window + * @param content the screen switcher + */ +@Composable +public fun DdsAppFrame( + navItems: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + navigation: Boolean = true, + barTitles: List = emptyList(), + barActions: Int = 2, + barReserve: (title: String) -> Dp = { 0.dp }, + list: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) { + val colors = DdsTheme.colors + // The window width comes from the constraints, not from the window info, so it follows what + // the frame is actually given (a split screen, a test that forces a size). + BoxWithConstraints(modifier.fillMaxSize().background(colors.bgDefault)) { + val width = ddsWindowWidth(maxWidth) + val rail = navigation && width != DdsWindowWidth.Compact && navItems.isNotEmpty() + val listPinned = navigation && width == DdsWindowWidth.Expanded && list != null + // What a screen gets is the window less the rail and the list, each with its hairline, and less the + // display cutouts the frame keeps clear of at the sides (whichever of the rail, list or content pads them). + val density = LocalDensity.current + val sides = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) + val cutouts = with(density) { (sides.getLeft(density, LocalLayoutDirection.current) + sides.getRight(density, LocalLayoutDirection.current)).toDp() } + val areaWidth = maxWidth - cutouts - + (if (rail) (DdsBar.rail + DdsBorder.hairline).dp else 0.dp) - + (if (listPinned) (DdsScreen.listPane + DdsBorder.hairline).dp else 0.dp) + val tallBar = barTitles.isNotEmpty() && + barNeedsTwoLines(width.gutter, areaWidth, barTitles, barActions, barReserve, DdsScreen.bodyMax.dp) + val frame = DdsFrame(width = width, rail = rail, listPinned = listPinned, tallBar = tallBar) + val currentOnSelect = rememberUpdatedState(onSelect) + // The screens draw the bottom tabs from this, so it exists only when there is a menu to draw. + val nav = remember(navItems, selectedIndex, navigation) { + if (navigation && navItems.isNotEmpty()) FrameNav(navItems, selectedIndex) { currentOnSelect.value(it) } else null + } + val start = WindowInsets.safeDrawing.only(WindowInsetsSides.Start) + val end = WindowInsets.safeDrawing.only(WindowInsetsSides.End) + CompositionLocalProvider(LocalDdsFrame provides frame, LocalFrameNav provides nav) { + Row(Modifier.fillMaxSize()) { + // The rail keeps itself clear of the start side. With no rail the list does, and + // with neither the content does (below). + if (rail) DdsNavRail(navItems, selectedIndex, onSelect) + if (listPinned) { + Row(Modifier.fillMaxHeight().then(if (rail) Modifier else Modifier.windowInsetsPadding(start))) { + Box(Modifier.width(DdsScreen.listPane.dp).fillMaxHeight().testTag(DdsTestTags.ListPane)) { + list?.invoke() + } + Box(Modifier.fillMaxHeight().width(DdsBorder.hairline.dp).background(colors.borderDefault)) + } + } + val contentInsets = if (rail || listPinned) end else start.union(end) + // The start inset the rail or the list already took is marked used, so a screen's own + // scaffold does not take it a second time. + val used = if (rail || listPinned) Modifier.consumeWindowInsets(start) else Modifier + Box(Modifier.weight(1f).fillMaxHeight().then(used).windowInsetsPadding(contentInsets)) { content() } + } + } + } +} diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/BarHeight.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/BarHeight.kt new file mode 100644 index 00000000..439c3ab9 --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/BarHeight.kt @@ -0,0 +1,75 @@ +package kr.devslab.dds.compose.layout + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsBar +import kr.devslab.dds.tokens.DdsScreen +import kr.devslab.dds.tokens.DdsSpace +import kr.devslab.dds.tokens.DdsTouch + +/** The room before a bar's title: the arrow or the mark and the gap after it. */ +internal val BarTitleStart: Dp = DdsSpace.s40.dp + +/** + * Whether any of [titles] needs two lines in a top bar at the current text size, so the whole + * window should use the tall bar. + * + * A title can use `min(areaWidth, columnMax)`, less a gutter on each side, less the room for the + * arrow or mark before it (40dp), less 48dp for each of [actions], less what [reserve] says for + * that title. It is measured in the bar's title style at the current font scale. + * + * The gutter is the window's: inside a frame it is the frame's, outside one it comes from the window. + * + * @param areaWidth the width the screen gets: the window minus the rail and a pinned list + * @param titles the names the app's top bars can show + * @param actions the most icon buttons a bar has at its end + * @param reserve the width a bar keeps free next to a given title for something of its own + * @param columnMax the widest the body column gets, the same value the bars are given + */ +@Composable +public fun ddsBarNeedsTwoLines( + areaWidth: Dp, + titles: List, + actions: Int = 2, + reserve: (title: String) -> Dp = { 0.dp }, + columnMax: Dp = DdsScreen.bodyMax.dp, +): Boolean = barNeedsTwoLines(ddsFrame().width.gutter, areaWidth, titles, actions, reserve, columnMax) + +/** + * The least height of every top bar in the window. The short bar is [DdsBar.top]. The tall bar is + * [DdsBar.topTall], or two lines of the title style plus a margin when the font is so large that + * two lines do not fit in that, so every bar in the window gets the same height whatever its title. + */ +@Composable +internal fun barMinHeight(tall: Boolean): Dp { + if (!tall) return DdsBar.top.dp + val lineHeight = DdsTheme.typography.title2.lineHeight + val twoLines = if (lineHeight.isSp) with(LocalDensity.current) { lineHeight.toDp() } * 2 else 0.dp + return maxOf(DdsBar.topTall.dp, twoLines + DdsSpace.s12.dp * 2) +} + +/** The same decision with the window's [gutter] given, for the frame, which has not provided itself yet. */ +@Composable +internal fun barNeedsTwoLines( + gutter: Dp, + areaWidth: Dp, + titles: List, + actions: Int, + reserve: (title: String) -> Dp, + columnMax: Dp, +): Boolean { + val measurer = rememberTextMeasurer() + val style = DdsTheme.typography.title2 + val density = LocalDensity.current + val column = minOf(areaWidth, columnMax) + return titles.any { title -> + val room = column - gutter * 2 - BarTitleStart - DdsTouch.min.dp * actions - reserve(title) + // Laid out on one unbroken line, so its width is how much room the title wants. + val wanted = measurer.measure(title, style, softWrap = false, maxLines = 1).size.width + wanted > with(density) { room.toPx() } + } +} diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/Body.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/Body.kt new file mode 100644 index 00000000..5706cb9c --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/Body.kt @@ -0,0 +1,93 @@ +package kr.devslab.dds.compose.layout + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.motion.ddsScreenContent +import kr.devslab.dds.tokens.DdsScreen + +/** + * Marks a column as the body of a screen: it grows to [max] and no further, and carries the + * test tag [DdsTestTags.Body] so a test can find and measure it. It does not fill the width + * by itself; [DdsBodyColumn] does that. + * + * @param max the widest the body gets; [DdsScreen.bodyMax] by default + */ +public fun Modifier.ddsBody(max: Dp = DdsScreen.bodyMax.dp): Modifier = + this.widthIn(max = max).testTag(DdsTestTags.Body) + +/** + * Gives something that is not in the body column (a bar, a divider, a banner) the same left + * and right edges as the text in the body: as wide as the body at most, and the window's + * gutter in from each side. Put it where the body is, centered the same way. + * + * @param max the widest the body gets; [DdsScreen.bodyMax] by default + */ +public fun Modifier.ddsBodyLines(max: Dp = DdsScreen.bodyMax.dp): Modifier = composed { + val gutter = ddsFrame().width.gutter + this.widthIn(max = max).fillMaxWidth().padding(horizontal = gutter) +} + +/** The default values of [DdsBodyColumn]. */ +public object DdsBodyDefaults { + /** The window's gutter on all four sides: the start line of everything in the body. */ + @Composable + public fun contentPadding(): PaddingValues = PaddingValues(ddsFrame().width.gutter) +} + +/** + * The column every screen's content goes in. It is centered in the area the frame leaves for + * the screen, no wider than [max], so on a wide window the text keeps one reading line and on a + * phone it fills the width. It scrolls, and it slides in when the screen arrives (it applies + * [ddsScreenContent] itself, with [paneTitle]; do not add it again). + * + * The text starts [contentPadding] in from the column's edge, which is the window gutter. Items + * that sit outside the column but should line up with its text use [ddsBodyLines]. + * + * @param scroll the scroll state, so a screen can keep or restore its position + * @param max the widest the column gets; [DdsScreen.bodyMax] by default + * @param spacing the gap between the blocks; [DdsScreen.blockGap] by default + * @param contentPadding the space between the column's edge and what is in it + * @param paneTitle the name of the screen, which a screen reader says when the screen arrives; + * pass the same words as the top bar's title (see [ddsScreenContent]) + */ +@Composable +public fun DdsBodyColumn( + modifier: Modifier = Modifier, + scroll: ScrollState = rememberScrollState(), + max: Dp = DdsScreen.bodyMax.dp, + spacing: Dp = DdsScreen.blockGap.dp, + contentPadding: PaddingValues = DdsBodyDefaults.contentPadding(), + paneTitle: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + Box(modifier.fillMaxSize().ddsScreenContent(paneTitle), contentAlignment = Alignment.TopCenter) { + Column( + Modifier + .ddsBody(max) + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(scroll) + .padding(contentPadding), + verticalArrangement = Arrangement.spacedBy(spacing), + content = content, + ) + } +} diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/Centered.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/Centered.kt new file mode 100644 index 00000000..217591eb --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/Centered.kt @@ -0,0 +1,143 @@ +package kr.devslab.dds.compose.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import kr.devslab.dds.compose.component.WordsText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kr.devslab.dds.compose.motion.ddsScreenContent +import kr.devslab.dds.compose.theme.DdsTheme +import kr.devslab.dds.tokens.DdsScreen +import kr.devslab.dds.tokens.DdsSpace + +/** + * The whole of a screen that has one thing to do: signing in, unlocking, a first question. + * What is in it stands as one block in the very middle of the area it is given, no wider than + * [max], with its pieces centered. A block left in a corner of a wide window reads as a screen + * that has not finished loading. + * + * The block keeps the window's gutter at its sides and [DdsScreen.blockGap] above and below, and + * its pieces are [DdsScreen.blockGap] apart, as the blocks of a body column are. When it is taller + * than the room it has (large text, a small window, the keyboard up) it starts at the top and + * scrolls, so nothing is cut off above the window and the last piece can be brought into view. + * + * The room the block is centered in is what the status bar, the navigation bar and the keyboard + * leave of the area: on an edge-to-edge window the block does not start under the status bar, and + * a field in it stays above the keyboard. Insets something above has already marked used are not + * taken again: inside [kr.devslab.dds.compose.component.DdsScaffold]'s padding the status bar + * (under the top bar) and the navigation bar are already covered, and only the keyboard's height + * beyond the navigation bar is left to take. The sides are the frame's ([DdsAppFrame] keeps the + * screens clear of a cutout there). The tag [DdsTestTags.CenteredArea] is on that room, inside the + * bars and the keyboard; [DdsTestTags.Centered] is on the block. + * + * It works inside [DdsAppFrame] (with `navigation = false`) and with no frame at all. It paints + * the page ([DdsTheme] `bgDefault`) behind its whole area, so as a screen of its own it covers the + * screen under it on every frame of a change, also in a `NavDisplay`, which paints nothing behind + * its entries. It draws no bars of its own. When the screen arrives the block slides in as a body + * column does, over the page, which stays still: it applies [ddsScreenContent] itself; do not add + * it again. + * + * @param modifier applied to the area the block is centered in, which fills what it is given + * @param max the widest the block gets; [DdsScreen.centeredMax] by default + * @param paneTitle the name of the screen, which a screen reader says when the screen arrives; + * pass the same words as the top bar's title, if there is one (see [ddsScreenContent]) + * @param content the pieces of the block, top to bottom + */ +@Composable +public fun DdsCenteredColumn( + modifier: Modifier = Modifier, + max: Dp = DdsScreen.centeredMax.dp, + paneTitle: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + val gutter = ddsFrame().width.gutter + Box( + modifier + .fillMaxSize() + // The page, behind the whole area and still while the block slides: the screen covers + // what is under it on every frame of a change, with or without a host that paints. + .background(DdsTheme.colors.bgDefault) + .ddsScreenContent(paneTitle) + // The bars above and below, and the keyboard: the block is centered in what they leave. + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)) + .testTag(DdsTestTags.CenteredArea), + ) { + // The scrolling column is at least as tall as the area, so a short block is arranged in its + // middle; a block taller than the area makes the column taller and it starts at the top. + Column( + Modifier + .fillMaxWidth() + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding(horizontal = gutter, vertical = DdsScreen.blockGap.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + Modifier.widthIn(max = max).fillMaxWidth().testTag(DdsTestTags.Centered), + verticalArrangement = Arrangement.spacedBy(DdsScreen.blockGap.dp), + horizontalAlignment = Alignment.CenterHorizontally, + content = content, + ) + } + } +} + +/** + * The title of a centered block ([DdsCenteredColumn]), and one sentence under it. + * + * The title is centered and its lines are made about the same length, so the last line is never + * a single word. A line break written into [title] is kept. A screen reader reads the title and + * the sentence as one heading. + * + * @param title the title; may hold line breaks + * @param modifier applied to the title and its sentence together, which take the block's width + * @param description one sentence under the title, in the secondary text color + */ +@Composable +public fun DdsCenteredTitle(title: String, modifier: Modifier = Modifier, description: String? = null) { + val colors = DdsTheme.colors + Column( + modifier.fillMaxWidth().semantics(mergeDescendants = true) { heading() }, + verticalArrangement = Arrangement.spacedBy(DdsSpace.s8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + WordsText( + title, + style = DdsTheme.typography.title1.copy(lineBreak = LineBreak.Heading), + color = colors.textPrimary, + textAlign = TextAlign.Center, + ) + if (description != null) { + WordsText( + description, + style = DdsTheme.typography.body2, + color = colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } +} diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/DdsFrame.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/DdsFrame.kt new file mode 100644 index 00000000..32e6a44a --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/DdsFrame.kt @@ -0,0 +1,65 @@ +package kr.devslab.dds.compose.layout + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import kr.devslab.dds.compose.component.DdsNavItem + +/** + * What the screen frame tells the screens inside it: how wide the window is and which pieces + * of the frame are showing. + * + * @property width the width class of the window. + * @property rail true when navigation is a rail on the leading edge instead of tabs at the bottom. + * @property listPinned true when the list pane stays beside the body instead of being a separate screen. + * @property tallBar true when the top bar is the tall variant. + */ +@Immutable +public class DdsFrame( + public val width: DdsWindowWidth, + public val rail: Boolean = false, + public val listPinned: Boolean = false, + public val tallBar: Boolean = false, +) { + override fun equals(other: Any?): Boolean = + other is DdsFrame && width == other.width && rail == other.rail && + listPinned == other.listPinned && tallBar == other.tallBar + + override fun hashCode(): Int { + var h = width.hashCode() + h = 31 * h + rail.hashCode() + h = 31 * h + listPinned.hashCode() + h = 31 * h + tallBar.hashCode() + return h + } + + override fun toString(): String = + "DdsFrame(width=$width, rail=$rail, listPinned=$listPinned, tallBar=$tallBar)" +} + +/** The frame the current screen is inside, or null when there is none. */ +public val LocalDdsFrame: ProvidableCompositionLocal = compositionLocalOf { null } + +/** + * The frame report for the current screen. Inside a frame this is exactly what the frame + * provided. Outside one it is a report with only the width, taken from the window. + */ +@Composable +public fun ddsFrame(): DdsFrame { + LocalDdsFrame.current?.let { return it } + val density = LocalDensity.current + val widthDp = with(density) { LocalWindowInfo.current.containerSize.width.toDp() } + return DdsFrame(width = ddsWindowWidth(widthDp)) +} + +/** The navigation the frame hands to its rail or bottom tabs. Not for apps to read. */ +internal class FrameNav( + val items: List, + val selectedIndex: Int, + val onSelect: (Int) -> Unit, +) + +internal val LocalFrameNav: ProvidableCompositionLocal = compositionLocalOf { null } diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/DdsTestTags.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/DdsTestTags.kt new file mode 100644 index 00000000..61349c70 --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/DdsTestTags.kt @@ -0,0 +1,25 @@ +package kr.devslab.dds.compose.layout + +/** Test tags on the parts of the screen frame, so device tests can find and measure them. */ +public object DdsTestTags { + /** The scrolling body column of a screen. */ + public const val Body: String = "dds-body" + + /** The title text in the top bar. */ + public const val TopBarTitle: String = "dds-top-bar-title" + + /** The navigation rail. */ + public const val Rail: String = "dds-rail" + + /** The pinned list pane. */ + public const val ListPane: String = "dds-list-pane" + + /** The bottom tabs. */ + public const val BottomTabs: String = "dds-bottom-tabs" + + /** The centered column used by sign-in style screens. */ + public const val Centered: String = "dds-centered" + + /** The area the centered column is centered in. */ + public const val CenteredArea: String = "dds-centered-area" +} diff --git a/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/WindowWidth.kt b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/WindowWidth.kt new file mode 100644 index 00000000..f3ca5db5 --- /dev/null +++ b/android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/WindowWidth.kt @@ -0,0 +1,22 @@ +package kr.devslab.dds.compose.layout + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kr.devslab.dds.tokens.DdsScreen + +/** The three widths a window can have. Compact is a phone, Medium a small tablet, Expanded a large one. */ +public enum class DdsWindowWidth { Compact, Medium, Expanded } + +/** Which of the three widths [width] falls in. 600 dp is the first Medium, 840 dp the first Expanded. */ +public fun ddsWindowWidth(width: Dp): DdsWindowWidth = when { + width >= DdsScreen.breakpointExpanded.dp -> DdsWindowWidth.Expanded + width >= DdsScreen.breakpoint.dp -> DdsWindowWidth.Medium + else -> DdsWindowWidth.Compact +} + +/** The side margin of screen content at this width: narrower on a phone, wider on anything bigger. */ +public val DdsWindowWidth.gutter: Dp + get() = when (this) { + DdsWindowWidth.Compact -> DdsScreen.gutter.dp + DdsWindowWidth.Medium, DdsWindowWidth.Expanded -> DdsScreen.gutterWide.dp + } diff --git a/android/dds-compose/src/main/res/values-ko/dds_a11y.xml b/android/dds-compose/src/main/res/values-ko/dds_a11y.xml index 5c66d2ba..8b632ff2 100644 --- a/android/dds-compose/src/main/res/values-ko/dds_a11y.xml +++ b/android/dds-compose/src/main/res/values-ko/dds_a11y.xml @@ -6,4 +6,5 @@ 오류 불러오는 중 시트 손잡이 + 뒤로 diff --git a/android/dds-compose/src/main/res/values/dds_a11y.xml b/android/dds-compose/src/main/res/values/dds_a11y.xml index 3c5cb811..2a643a40 100644 --- a/android/dds-compose/src/main/res/values/dds_a11y.xml +++ b/android/dds-compose/src/main/res/values/dds_a11y.xml @@ -8,4 +8,5 @@ Error Loading Drag handle + Back diff --git a/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/layout/WindowWidthTest.kt b/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/layout/WindowWidthTest.kt new file mode 100644 index 00000000..43795e03 --- /dev/null +++ b/android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/layout/WindowWidthTest.kt @@ -0,0 +1,30 @@ +package kr.devslab.dds.compose.layout + +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Test + +class WindowWidthTest { + @Test + fun theTwoLinesBetweenTheThreeWidths() { + assertEquals(DdsWindowWidth.Compact, ddsWindowWidth(599.dp)) + assertEquals(DdsWindowWidth.Medium, ddsWindowWidth(600.dp)) + assertEquals(DdsWindowWidth.Medium, ddsWindowWidth(839.dp)) + assertEquals(DdsWindowWidth.Expanded, ddsWindowWidth(840.dp)) + } + + @Test + fun theGutterFollowsTheWindow() { + assertEquals(16.dp, DdsWindowWidth.Compact.gutter) + assertEquals(24.dp, DdsWindowWidth.Medium.gutter) + assertEquals(24.dp, DdsWindowWidth.Expanded.gutter) + } + + @Test + fun framesWithTheSameReportAreEqual() { + assertEquals( + DdsFrame(DdsWindowWidth.Expanded, rail = true, listPinned = true), + DdsFrame(DdsWindowWidth.Expanded, rail = true, listPinned = true), + ) + } +} diff --git a/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsDimens.kt b/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsDimens.kt index 1560ecdd..45bb9cde 100644 --- a/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsDimens.kt +++ b/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsDimens.kt @@ -62,10 +62,12 @@ object DdsRow { const val twoLine: Int = 72 } -/** Top bar and bottom navigation bar heights (dp). */ +/** Top bar, bottom navigation bar and side rail sizes (dp). `rail` is the rail width; `topTall` is the tall top bar height. */ object DdsBar { const val top: Int = 64 const val bottom: Int = 80 + const val rail: Int = 80 + const val topTall: Int = 104 } /** Icon sizes (dp). */ @@ -80,10 +82,14 @@ object DdsBorder { const val focus: Int = 2 } -/** Screen gutters, the width (dp) at which the wide gutter and rail apply, and the gap between blocks. */ +/** Screen gutters, the widths (dp) at which the wide gutter and the side-by-side layout apply, the widest reading column and centered form, the list pane width, and the gap between blocks. */ object DdsScreen { const val gutter: Int = 16 const val gutterWide: Int = 24 const val breakpoint: Int = 600 const val blockGap: Int = 24 + const val breakpointExpanded: Int = 840 + const val bodyMax: Int = 680 + const val listPane: Int = 264 + const val centeredMax: Int = 400 } diff --git a/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsTokensInfo.kt b/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsTokensInfo.kt index d6ffed51..28c6a0d7 100644 --- a/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsTokensInfo.kt +++ b/android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsTokensInfo.kt @@ -6,5 +6,5 @@ object DdsTokensInfo { const val sourcePackage: String = "@devslab/dds-tokens" const val sourceRepository: String = "https://github.com/devslab-kr/dds" /** SHA-256 of the token files (tokens/ in the source repository) these values were generated from. */ - const val sourceSha256: String = "2189b9aa2e5a856a1f9ed2c3c109087bf85e422a2a99255e7be1b4babcaceaf9" + const val sourceSha256: String = "738637d53e5e35b63b8afe09e0c13e7171e38c70ecf1af528a757f44370a87b6" } diff --git a/android/dds-tokens/src/main/res/values/dds_tokens.xml b/android/dds-tokens/src/main/res/values/dds_tokens.xml index 3d61030c..26cff995 100644 --- a/android/dds-tokens/src/main/res/values/dds_tokens.xml +++ b/android/dds-tokens/src/main/res/values/dds_tokens.xml @@ -57,6 +57,8 @@ 72dp 64dp 80dp + 80dp + 104dp 20dp 24dp 1dp @@ -65,6 +67,10 @@ 24dp 600dp 24dp + 840dp + 680dp + 264dp + 400dp 100 150 200 diff --git a/android/gradle.properties b/android/gradle.properties index 95eaac94..9e85037c 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -8,4 +8,4 @@ kotlin.code.style=official # Android releases are versioned apart from the web packages (docs/decisions.md D-048). # The release workflow overrides VERSION_NAME from the android-v* tag. GROUP=kr.devslab -VERSION_NAME=0.6.0 +VERSION_NAME=0.7.0 diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index f0862c8e..2090aeff 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -16,8 +16,9 @@ dependencyResolutionManagement { rootProject.name = "dds-android" -// Published: kr.devslab:dds-android-tokens, kr.devslab:dds-compose. +// Published: kr.devslab:dds-android-tokens, kr.devslab:dds-compose, kr.devslab:dds-compose-testing. include(":dds-tokens") include(":dds-compose") +include(":dds-compose-testing") // Not published: every value and helper on screen, for visual checks. include(":catalog") diff --git a/docs/backlog.md b/docs/backlog.md index 0b3d9698..37fc133d 100644 --- a/docs/backlog.md +++ b/docs/backlog.md @@ -315,7 +315,9 @@ D-048. 가이드 [`mobile-native.ko.md`](mobile-native.ko.md), 설계 - [x] 0.5.0 화면 전환을 실물 폰에서 프레임 단위로 녹화(2026-11-12, 정산나우, 접힌 화면·어두운 화면): 탭·열기· 뒤로에서 비는 프레임 없음, 뒤로 갈 때 내용이 앞쪽에서 밀려 들어옴. 가이드 §2.3 "0.5.0 실물 폰 측정". - [ ] **0.5.0을 실물 폰에서 재지 못한 것** — 뒤로 쓸어 넘기기(예측형 뒤로 가기. 정산나우 폰은 버튼 3개 방식), - 접고 펴기·돌리기의 프레임, 레일이 있는 펼친 화면, 밝은 화면. + 접고 펴기·돌리기의 프레임, 펼친 세로, 밝은 화면. 펼친 가로(933×704dp)는 정산나우가 Maven Central의 + 0.5.0으로 전체 연결 테스트 88건과 "탭을 바꾸는 동안 빈 프레임 없음" 검사를 통과시켰다(2026-11-12). + 녹화해서 프레임으로 본 것은 아니다. - [x] 0.6.0 제품이 정하는 색(D-053, 2026-11-12) — **배포했다(#145, `android-v0.6.0`).** 대비 예외를 조합별로 적기(`dds_allow_contrast_*`, `DdsContrastPair`), 바탕 셋(`dds_color_bg_default`·`_subtle`· `_elevated`), 채운 보조 버튼과 `Plain` 버튼, 그림자 없는 메뉴·끝 맞춤(`DdsMenuAlign.End`)· @@ -332,19 +334,38 @@ D-048. 가이드 [`mobile-native.ko.md`](mobile-native.ko.md), 설계 페이지에서 레일·시트·다이얼로그의 픽셀, 제품 바탕 위의 컨트롤 테두리 3:1. - [ ] **입력칸 요청(정산나우)** — 글자의 사본을 남기지 않는 입력칸, 초점이 가면 화면 안으로 끌어오기 (bring-into-view), 최소 줄 수(`minLines`). 그리고 단계 화면의 틀. -- [ ] **화면 틀(0.7.0)** — 다른 브랜치에서 만들고 있다(D-052). 정산나우 요청(2026-11-11, 11-12 추가): 본문 최대 폭·넓은 화면 기준·고정 목록 폭 +- [ ] **화면 틀(0.7.0) — 만들었다, 소유자의 머지·배포 결정 대기(2026-11-12).** 브랜치 `feat/android-screen-frame`. + `DdsAppFrame`, 새 `DdsScaffold`, `DdsBodyColumn`·`ddsBody`·`ddsBodyLines`, 본문 선에 서는 `DdsTopBar` + (`onBack`, 두 줄 제목), `DdsCenteredColumn`·`DdsCenteredTitle`, `DdsPaneList`·`DdsPaneItem`, 새 꾸러미 + `dds-compose-testing`(`DdsFrameCheck`). 기기 테스트 전체 실물 폰 접은 화면(1248x1972) 170개, 펼친 화면(2448x1848) 174개 실행, + 실패 0, 건너뜀 2. 카탈로그를 새 틀로 옮기고 Layout 쪽을 더했다. 가이드 §13, 변경 기록 0.7.0, D-052. **배포는 + 정산나우가 로컬 빌드로 옮겨 폰의 창 세 가지에서 확인한 뒤.** 인계 메모: 공유 노트 + `dds-android-0.7.0-consumer.md`. 확인하지 못한 것: 뒤로 밀기, 진짜 옆쪽 구멍, 오른쪽에서 왼쪽 기기 언어, + 화면 분할, 따로 만든 앱에서 테스트 꾸러미, TalkBack. + 원래 요청 — 정산나우 요청(2026-11-11, 11-12 추가): 본문 최대 폭·넓은 화면 기준·고정 목록 폭 숫자, 창 폭 분류, 화면 틀(하단 탭 / 고정 왼쪽 레일 / 고정 목록), 본문 기둥, 본문에 맞춘 상단 줄, 시작선 검사 도우미. 넓은 화면의 고정 목록과 좁은 화면의 "목록 → 화면 → 뒤로"가 같은 목록 하나에서 나오게 - 한다(정산나우가 이렇게 정했다). 소유자가 정할 것: 옆 여백(정산나우 20, DDS 16/24). 중간 폭(600~839)은 - 정산나우에서 "레일만, 관리 목록은 더보기 안"으로 정했다고 전달받았다. DDS 규칙으로 삼을지 확인한다. + 한다(정산나우가 이렇게 정했다). 소유자가 정한 것: 옆 여백 + 16/24, 중간 폭(600~839)은 레일만, DDS 틀 부품이 메뉴를 그리는 방식, 검사 도우미는 새 꾸러미로. 설계: + `docs/superpowers/specs/2026-11-12-dds-android-screen-frame-design.ko.md`. 배포는 정산나우가 실물 폰의 창 + 세 가지에서 확인한 뒤에 한다. 정산나우는 틀을 커밋했고(`7f6f3b3`) 실물 폰의 창 세 가지(펼친 세로·펼친 가로·접힌)에서 확인했다(밝은 화면과 일반 폰 폭은 아직). 요청서: 공유 노트 `dds-android-frame-request-2026-11-11.md`. -- [ ] **한 가지 일만 하는 화면(0.7.0 이후 후보)** — 로그인 전·잠금. 가운데 묶음(폭 최대 400dp, 넘치면 위에서부터 +- [ ] **한 가지 일만 하는 화면(0.7.0, 화면 틀과 함께 만들었다 — 배포 대기)** — 로그인 전·잠금. 가운데 묶음(폭 최대 400dp, 넘치면 위에서부터 스크롤), 가운데 정렬 제목은 줄 길이를 고르게. 정산나우 `JnGateColumn`·`JnGateTitle`(`fb40b66`). - [ ] **스플래시 로고가 가는 길을 화면이 고르기(모션 요청, 0.7.0 이후 후보)** — 시간, 곡선, 떠나기 전 멈춤. 지금은 300ms 고정이라 정산나우는 로그인 전 화면에서 직접 그렸다(`1d8a124`). - [ ] **화면 덩어리가 차례로 도착하는 값(모션 요청, 0.7.0 이후 후보)** — `ddsAppear`의 목록용 30ms 간격은 화면 하나의 큰 덩어리 서너 개에는 너무 빨랐다(정산나우 소유자 판단). 목록용과 따로 간격·시간·오르는 거리를 정한다. +- [ ] **입력칸 세 건(화면 틀 다음, 정산나우 요청 2026-11-12)** — ① 글자 사본을 갖지 않는 입력칸: + `DdsTextField`·`DdsSearchField`는 `value: String` 방식이라 포커스를 잃거나 키보드가 입력을 마무리할 때 + 옛 값이 새 값을 덮을 수 있다(정산나우에서 `6.999`가 `6`이 된 결함. DDS 부품에서 재현해 보지는 않았다). + `TextFieldState`를 받는 형태가 필요하고, 번호 칸은 저장 꾸러미에 넣지 않을 수 있어야 한다. ② 칸 이름·칸· + 아래 한 줄을 한 묶음으로 보이게 끌어오기(키보드가 뜨면 이름과 오류 문구가 잘린다). ③ 여러 줄(`minLines`). + 정산나우 `JnTextField`·`JnFieldBlock`(`42ae9d8`). +- [ ] **단계로 하는 화면의 틀(화면 틀 다음, 정산나우 요청 2026-11-12)** — 단계 수는 상단 줄 안에, 단계 줄은 + 바로 아래 4dp, 이전·다음은 아래 고정 48dp, 키보드가 떠도 제자리. 정산나우 `JnWizardFrame`(`195ea13`). + 화면 틀의 `barReserve`가 이것을 위한 자리다. - [ ] 채운 버튼 위의 포커스 링이 채움색에 묻힌다(웹 공통). - [ ] 정산나우·Qooroo를 0.2.0 부품으로 옮기기(각 레포에서). - [ ] **웹 규칙에 없어 정해야 하는 것 13가지** — 가이드 `mobile-native.ko.md` §10. 소유자가 DDS 규칙으로 diff --git a/docs/decisions.md b/docs/decisions.md index 3f3ee07b..88c8ae3e 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -87,6 +87,76 @@ dark), the menu loses its shadow, and brand text is newly checked on the subtle --- +## D-052 — 화면 틀: 창 폭이 메뉴를 정하고, 본문은 한 기둥에 선다 (2026-11-12) + +**상태.** 만들었다. 아직 배포 전(안드로이드 0.7.0, 브랜치 `feat/android-screen-frame`). 정산나우가 로컬 빌드로 옮겨 실물 폰의 창 세 가지에서 확인한 뒤에 배포한다. 설계 문서: `docs/superpowers/specs/2026-11-12-dds-android-screen-frame-design.ko.md`(설계와 달라진 것은 그 문서의 "달라진 것"). + +**문제.** 정산나우에서 소유자가 펼친 폰으로 찾았다. 화면마다 본문 시작선이 달랐고(펼친 가로에서 187, +147, 127, 끝에서 끝까지), 왼쪽 레일은 탭 화면에만 있었으며, 상단 줄은 화면 왼쪽 끝에서 본문은 가운데에서 +시작했다. 원인은 하나였다. DDS에 숫자(여백 16/24, 기준 600)는 있는데 그 숫자를 지키게 하는 부품이 없어서 +화면마다 폭을 손으로 적었다. + +**결정.** 소유자가 2026-11-12에 정했다. + +- **옆 여백은 16(창 폭 600 미만) / 24(600 이상)** 이다. 가이드 §10의 2번에 제안값으로 남아 있던 것을 + 확정한다. 정산나우의 20은 이 값으로 옮긴다. 여백은 화면에 남은 영역이 아니라 창 폭으로 정한다. +- **숫자 넷을 더한다:** 고정 목록 시작 840, 본문 기둥 최대 680(여백 포함), 고정 목록 폭 264, 가운데 묶음 + 최대 400. 정산나우가 실물 폰에서 검증한 값이다. +- **DDS 틀 부품이 메뉴를 그린다.** `DdsAppFrame`을 화면 전환기 바깥에 두고, 창 폭에 따라 레일(600 이상)과 + 고정 목록(840 이상)을 그린다. 하단 탭은 최상위 화면이 전환기 안에서 그린다. 상세 화면이 탭 줄까지 덮어야 + 하기 때문이다. 화면은 폭을 재지 않고 틀이 알려 주는 값만 읽는다. +- **중간 폭(600~839)은 레일만** 둔다. 목록은 한 화면으로 열고 뒤로 돌아온다. +- **본문은 한 기둥에 서고 상단 줄은 그 선에 맞춘다.** 뒤로 화살표와 마지막 아이콘은 눌리는 영역이 아니라 + 보이는 그림이 본문 선에 서고, 제목은 로고 뒤든 화살표 뒤든 같은 자리에서 시작한다. 두 줄 제목은 창 + 단위로 정해 모든 상단 줄의 높이가 같다. +- **검사 도우미를 새 꾸러미(`dds-compose-testing`)로 낸다.** 앱이 자기 화면들의 시작선과 메뉴를 잰다. + +**대안.** ① Material의 적응형 틀을 감싼다 — 새 의존성, 목록 폭과 칸 전환을 그쪽 규칙으로 정함, 시작선 +문제는 풀어 주지 않음. ② 숫자와 도우미만 준다 — 원인이 그대로 남는다. ③ 하단 탭도 틀이 그린다 — 상세 +화면을 열 때 탭 줄이 전환과 따로 사라져 내용이 튄다. + +**영향.** 기존 코드는 그대로 컴파일된다. `DdsTopBar`는 넓은 창에서 내용이 본문 기둥 폭에 모이고 600 +이상에서 옆 여백이 24가 된다. 레일·하단 탭 라벨은 두 줄까지 쓴다. Maven 꾸러미가 셋이 된다. 정산나우는 +틀·본문·상단 줄·측정 테스트를 DDS 것으로 바꾸고 여백이 20에서 16/24가 된다. Qooroo는 창 폭 분류·여백·본문 +기둥만 골라 쓴다. + +**구현하며 더 정한 것.** 가운데 묶음은 시스템 줄과 키보드를 피하고(입력칸이 키보드 위에 남는다), 조각 사이를 +24 띄우고, 화면과 함께 밀려 들어오며, 자리 전체에 페이지 바탕을 칠한다(D-051 — 모든 화면은 아래 화면을 덮는다. +`NavDisplay`는 화면 뒤에 아무것도 칠하지 않는다). 고정 목록의 고르지 않은 줄은 레일과 같은 회색이다. 200% +글자에서 세 줄이 필요한 레일 라벨은 두 번째 줄 뒤에서 자르고 레일은 80 그대로다. 옆쪽 카메라 구멍이 있으면 +레일이 차지하는 폭은 구멍 + 80이다. 검사 꾸러미는 Espresso 3.7.0을 함께 가져온다(Compose BOM의 3.5.0은 +Android 17에서 돌지 않는다). 검사는 가운데 묶음 하나를 화면의 본문 하나로 센다. + +**검증.** 단위 테스트·린트·빌드 통과. 기기 테스트 전체를 결과 파일로 읽었다 — 실물 폰(SM-F971N, Android 17, +접은 화면 1248x1972)에서 170개 실행, 실패 0, 오류 0, 건너뜀 2, 펼친 화면(2448x1848)에서 최종 코드로 174개 실행, +실패 0, 오류 0, 건너뜀 2(폰에서 돌 수 없는 메뉴의 색 값 검사 둘, 0.6.0과 같다). +틀 테스트는 테스트 안에서 창을 475×751, 704×933, 933×704로 고정한다. 견본 앱 하나(탭 둘, 들어간 화면, 목록이 +있는 영역, 메뉴를 끈 화면, 로그인 묶음)를 세 창에서 `DdsFrameCheck`로 쟀고, 제품 페이지 색에서도 쟀다. 시계를 +멈추고 프레임마다 본 것: 화면이 바뀌는 동안 레일의 자리와 픽셀이 그대로다. 탭 화면 위로 연 화면이 빈 프레임 +없이 하단 탭을 덮는다. 가운데 묶음 화면이 모든 프레임에서 아래 화면을 페이지 바탕으로 덮는다. 새 테스트는 +모두 실패하는 것을 먼저 보았다. 예외: 견본 앱 테스트의 페이지 색 검사와 `assertSameLines` 호출은 실패하는 것을 +본 적이 없다. 카탈로그를 새 틀로 옮기고 Layout 쪽을 더해, 고정한 에뮬레이터에서 세 창(밝은 +화면), 933×704 어두운 화면, 475×751 가장 큰 글자와 키보드를 캡처하고 본문의 선을 그어 보았다. + +**확인하지 못한 것.** 정산나우가 자기 폰에서 확인(배포 조건). 폰에서 카탈로그. 틀과 함께 하는 뒤로 밀기. 진짜 +옆쪽 카메라 구멍(테스트 안에서만 설정)과 `DdsFrameCheck`의 옆 여백 처리. 오른쪽에서 왼쪽으로 쓰는 기기 언어. +틀 밖의 화면 분할. 따로 만든 앱에서 테스트 꾸러미 받기, 릴리스 워크플로가 세 번째 꾸러미를 올리는지. TalkBack. + +**재검토 시점.** 목록과 상세를 반반으로 나누는 앱이 둘 이상이 되면(지금은 Qooroo 하나). 반쯤 접은 자세를 +써야 하는 화면이 생기면. 실물 폰 확인에서 선이 어긋나면. + +**English.** Built, not released (Android 0.7.0). The window's width decides the menus (bottom tabs under +600dp drawn by the top-level screens, a rail from 600 and a pinned list from 840 drawn by `DdsAppFrame` +outside the screen switcher); the gutter is 16 / 24 by the window's width; one body column of at most 680dp +per screen; the top bar stands on the body's lines; a centered block of at most 400dp for a screen with one +thing to do; `kr.devslab:dds-compose-testing` lets an app check its own screens. Verified: the whole device +suite on the phone (SM-F971N, Android 17, folded 1248x1972: 170 run; unfolded 2448x1848 at the final code: 174 run), 0 failed, 2 skipped (the menu's +exact-color checks); the catalog looked at on an emulator forced to the three windows. Not verified: +JeongsanNow on its phone (the condition for the release), the back swipe, a real side cutout, a +right-to-left device language, split screen, the testing artifact from a separate build, TalkBack. + +--- + ## D-051 — 화면은 겹쳐서 바꾼다: 바뀌는 동안 언제나 두 화면이 있다 (2026-11-11) **문제.** 안드로이드 0.3.0과 0.4.0은 화면 전환의 페이드를 차례로 뺐다. 하단 탭의 150ms 페이드는 diff --git a/docs/mobile-native.ko.md b/docs/mobile-native.ko.md index 40d9dd63..87cecd8a 100644 --- a/docs/mobile-native.ko.md +++ b/docs/mobile-native.ko.md @@ -20,7 +20,8 @@ iOS는 아직 범위 밖이다. | 좌표 | 내용 | | --- | --- | | `kr.devslab:dds-android-tokens` | 값만. Kotlin 상수와 XML 리소스. Compose가 필요 없다 | -| `kr.devslab:dds-compose` | 테마, 브랜드, 모션·불러오기 도우미. 위 좌표를 함께 가져온다 | +| `kr.devslab:dds-compose` | 테마, 브랜드, 모션·불러오기 도우미, 부품, 화면 틀(§13). 위 좌표를 함께 가져온다 | +| `kr.devslab:dds-compose-testing` | 앱의 기기 테스트 전용. `DdsFrameCheck`가 앱 화면을 틀에 대고 잰다(§13.6). 0.7.0부터 | 최소 Android 8(API 26). 사용법과 버전 규칙은 [`android/README.ko.md`](../android/README.ko.md). @@ -637,8 +638,8 @@ Material3의 `primary`는 채움 면과 글자색을 겸하는데 DDS는 둘을 | 버튼 | `DdsControl.sm / md / lg` | 40 / **48** / 56 | §10의 2번 | | 입력칸 | `DdsField.height` | 56 | §10의 2번 | | 목록 행 | `DdsRow.min / twoLine` | 56 / 72 | §10의 2번 | -| 상단 바 / 아래 탭 | `DdsBar.top / bottom` | 64 / 80 | §10의 2번 | -| 화면 여백 / 블록 사이 | `DdsScreen.gutter / gutterWide / blockGap` | 16 / 24(폭 600 이상) / 24 | §10의 2번 | +| 상단 바 / 아래 탭 / 옆 레일 / 높은 상단 바 | `DdsBar.top / bottom / rail / topTall` | 64 / 80 / 80 / 104 | §10의 2번 | +| 화면 여백 / 블록 사이 / 넓은 화면 | `DdsScreen.gutter / gutterWide / blockGap / breakpointExpanded / bodyMax / listPane / centeredMax` | 16 / 24(폭 600 이상) / 24 / 840(목록과 상세를 나란히) / 680(본문 최대 폭) / 264(목록 폭) / 400(가운데 폼 최대 폭) | §10의 2번 | | 최소 터치 | `DdsTouch.min / large` | 48 / 56 | §9 | - **버튼 높이는 48 하나다.** `lg`(56)는 화면 맨 아래의 주 버튼 하나에만 쓴다. @@ -742,7 +743,7 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera | # | 빠진 규칙 | 지금 넣어 둔 값 | 근거 | | --- | --- | --- | --- | | 1 | 메인 색 하나에서 나머지 다섯(누름, 옅은 배경, 브랜드 글자, 포커스, 어두운 화면용)을 정하는 법 | 없음 — 웹처럼 제품이 직접 정한다. 적지 않은 값은 DDS 기본값 | 웹에는 asklinq·booklinq 예시만 있다. 정산나우는 메인 색이 DDS와 같은 색 계열이라 한 줄로 충분하다 | -| 2 | 터치 화면의 컨트롤 높이, 입력칸, 목록 행, 바 높이, 화면 여백 | 40·48·56 / 56 / 56·72 / 64·80 / 16·24 | 웹 버튼은 32·40·48. 세 앱과 Material3 기본값 | +| 2 | 터치 화면의 컨트롤 높이, 입력칸, 목록 행, 바 높이, 화면 여백 | 40·48·56 / 56 / 56·72 / 64·80 / 16·24 | 웹 버튼은 32·40·48. 세 앱과 Material3 기본값. **옆 여백은 정해졌다(2026-11-12, D-052): 창 폭 600dp 미만 16, 600부터 24. 창 폭으로 정한다(§13)** | | 3 | 150ms, 순차 30ms × 6, 밀기 24dp, `Travel` 이징, 스플래시 150ms·1초 | 정산나우 확정값 | 웹 토큰에는 100·200·300과 이징 셋만 있다 | | 4 | 자리 표시가 움직이는가 | 정지 | 웹은 1600ms로 맥동한다. 정산나우·Qooroo는 정지 | | 5 | 카드 그림자 | 없음, 선만 | 웹은 `elevation.1`. 세 앱 모두 그림자를 쓰지 않는다 | @@ -815,7 +816,8 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera | `DdsCard`, `DdsSection`, `DdsDivider`, `DdsListRow` | `bg.elevated` + 1dp 선 + 둥글기 12, 그림자 없음. 섹션 제목은 제목으로 읽힌다. 행은 56 또는 72 | Card | | `DdsNotice`, `DdsBadge`, `DdsChip` | 뜻마다 아이콘과 색 쌍이 다르다. 배지는 점으로 상태를 한 번 더 알린다. 중립은 선을 둔다 | Alert, Badge, Tag | | `DdsEmptyState`, `DdsSpinner` | 점선 테두리, 제목, 한 문장, 동작은 많아야 하나 | EmptyState, Spinner | -| `DdsTopBar`, `DdsBottomBar`, `DdsNavRail`, `DdsScaffold` | 바는 고정이고 1dp 선으로 구분한다. 600dp 미만은 아래 탭, 600dp부터 레일. 선택은 알약·색·굵기가 함께 바뀐다 | — (모바일에만) | +| `DdsTopBar`, `DdsBottomBar`, `DdsNavRail`, `DdsScaffold` | 바는 고정이고 1dp 선으로 구분한다. 600dp 미만은 아래 탭, 600dp부터 레일. 선택은 알약·색·굵기가 함께 바뀐다. 0.7.0부터 상단 줄은 본문의 선에 서고 라벨은 두 줄까지 쓴다(§13) | — (모바일에만) | +| `DdsAppFrame`, `DdsBodyColumn`, `DdsCenteredColumn`, `DdsCenteredTitle`, `DdsPaneList`, `DdsPaneItem` (0.7.0) | 화면 틀. 창 폭이 메뉴를 정하고, 화면마다 본문 기둥 하나, 한 가지 일만 하는 화면의 가운데 묶음, 고정 목록의 줄(§13) | — (모바일에만) | | `DdsDialog` | 결정을 확인한다. 둥글기 12, `bg.elevated` | Dialog | 캡처(밝은 화면, 어두운 화면, 글자 200%, 큰 글자, 넓은 화면)를 읽고 나온 규칙: @@ -875,3 +877,254 @@ Compose 벡터는 `dds-icons`의 SVG에서 **생성**한다(`node scripts/genera 종이색 페이지 위의 흰 카드, 버튼 넷, 끝 맞춤 메뉴). 아직 없는 것: 날짜·시간 선택. `DdsTheme` 아래에서 Material3 부품을 쓴다. + +## 13. 화면 틀 (0.7.0, D-052) + +앱의 모든 화면은 한 시작선에 서고, 상단 줄은 본문과 같은 선에 선다. 정산나우가 펼친 폰에서 찾은 문제가 +이것이었다. 화면마다 본문이 187, 147, 127dp에서 시작했고, 레일은 탭 화면에만 있었고, 상단 줄은 창 끝에서 +본문은 가운데에서 시작했다. DDS에 숫자는 있는데 그 숫자를 지키게 하는 부품이 없어서, 화면마다 폭을 손으로 +적었기 때문이다. 0.7.0이 그 부품을 더한다. 부품을 쓰면 선이 맞는다. 화면은 아무것도 재지 않는다. + +### 13.1 규칙 + +1. **창 폭이 메뉴를 정한다.** 600dp 미만: 최상위 화면에 하단 탭. 600dp부터: 레일. 840dp부터: 레일과, + 화면이 목록이 있는 영역에 속하면 그 옆에 목록. 화면은 창을 재지 않고 틀이 정한 것(`ddsFrame()`)을 읽는다. +2. **옆 여백은 남은 자리가 아니라 창 폭으로 정한다.** 600 미만 16dp, 600부터 24dp. 고정 목록 옆의 화면은 + 587dp밖에 안 되지만 창이 넓으니 24다. 상단 줄, 본문, 목록이 모두 자기 창의 여백을 쓴다. (§10의 2번에서 + 여백은 이것으로 정해졌다.) +3. **본문 기둥은 화면마다 하나다.** 여백을 포함해 680dp를 넘지 않고, 레일과 목록이 남긴 자리의 가운데에 선다. +4. **상단 줄은 본문의 선에 선다.** 상단 줄의 내용은 본문과 같은 폭으로 같은 자리에 놓인다. 뒤로 화살표의 + 24dp 그림은 시작선에서 시작하고, 마지막 아이콘의 24dp 그림은 끝선에서 끝난다. 아이콘마다 눌리는 영역 + 48dp는 여백 쪽으로 12dp 나간다. 제목은 로고 뒤든 화살표 뒤든 시작선에서 40dp 뒤에서 시작한다. 그래서 + 화면을 열어도 제목이 옆으로 움직이지 않는다. 둘 다 없으면 시작선에서 시작한다. +5. **레일은 틀이, 하단 탭은 화면이 그린다.** 레일은 모든 화면에 같고, 옆의 화면이 바뀌는 동안 움직이면 + 안 된다. 하단 탭은 최상위 화면의 것이다. 상세 화면은 열리면서 탭을 덮어야 하는데, 탭이 그 아래 화면의 + 일부일 때만 그렇게 된다(D-051). +6. **한 가지 일만 하는 화면**(로그인, 잠금 풀기)은 400dp를 넘지 않는 한 묶음으로, 주어진 자리의 한가운데에 선다. +7. **상단 줄의 높이는 창이 정한다.** 앱이 알려 준 제목 중 하나라도 지금 글자 크기에서 한 줄에 들어가지 + 않으면, 그 창의 모든 상단 줄이 두 줄, 104dp가 된다. 그래야 화면마다 본문이 위아래로 튀지 않는다. + +### 13.2 숫자 + +| 무엇 | 값 | 토큰 | +| --- | --- | --- | +| 중간 창 시작 / 넓은 창 시작 | 600 / 840 | `DdsScreen.breakpoint` / `breakpointExpanded` | +| 옆 여백, 600 미만 / 600부터 | 16 / 24 | `DdsScreen.gutter` / `gutterWide`, 또는 `DdsWindowWidth.gutter` | +| 본문 기둥 최대(여백 포함) | 680 | `DdsScreen.bodyMax` | +| 고정 목록 | 264, 그 뒤에 1dp 선 | `DdsScreen.listPane` | +| 가운데 묶음 최대 | 400 | `DdsScreen.centeredMax` | +| 레일 | 80 | `DdsBar.rail` | +| 상단 줄 / 두 줄 제목일 때 | 64 / 104 | `DdsBar.top` / `topTall` | +| 제목 시작 | 시작선 + 40 | — | +| 아이콘 버튼 / 그 그림 | 48 / 24, 여백 쪽으로 12 나감 | `DdsTouch.min`, `DdsIcon.md` | +| 제목 앞의 로고 | 폭 최소 28, 그 뒤 12 띄우고 제목 | — | + +SM-F971N의 창 세 가지에서는 이렇게 된다(이 크기로 고정한 에뮬레이터의 카탈로그와 기기 테스트에서 쟀다). + +| 창 | 메뉴 | 본문 글자의 선(창 시작부터 dp) | +| --- | --- | --- | +| 475×751 (접힌) | 최상위 화면에 하단 탭 | 16과 459 | +| 704×933 (펼친 세로) | 레일(80 + 1dp 선) | 105와 680 | +| 933×704 (펼친 가로) | 레일 | 191과 823 | +| 933×704, 목록이 있는 영역 | 레일, 목록 81~345, 1dp 선 | 목록 글자 105, 본문 370과 909 | + +### 13.3 부품 + +| 부품 | 하는 일 | +| --- | --- | +| `DdsWindowWidth`, `ddsWindowWidth(width)`, `DdsWindowWidth.gutter` | 600과 840을 기준으로 Compact / Medium / Expanded. 각각의 옆 여백 | +| `DdsAppFrame(navItems, selectedIndex, onSelect, navigation, barTitles, barActions, barReserve, list) { … }` | 앱에 하나. 화면 전환기(`NavDisplay`나 `DdsScreenHost`) 바깥에 둔다. 레일과 고정 목록을 그린다. 그것들이 나타나거나 사라져도 내용은 제자리에 있다(접거나 돌려도 스크롤 위치와 입력한 글자가 남는다). 옆쪽의 카메라 구멍을 피한다. 오른쪽에서 왼쪽으로 쓰는 언어에서는 뒤집힌다. `navigation = false`면 메뉴를 모두 끈다(로그인 전, 스캔, 작성 화면). 바탕은 `bgDefault` | +| `DdsFrame`, `LocalDdsFrame`, `ddsFrame()` | 틀이 정한 것: `width`, `rail`, `listPinned`, `tallBar`. 틀 밖에서 `ddsFrame()`은 창으로 정한 `width`만 갖는다 | +| `DdsScaffold(topBar, modifier, bottomTabs) { padding -> … }` | 틀 안의 화면 하나. 최상위 화면은 `bottomTabs = true`. 탭은 Compact 창에서만, 틀이 받은 항목과 선택으로 그려진다. `padding`이 이미 비켜 준 시스템 영역(상단 줄 밑의 상태 표시줄, 내용 밑의 내비게이션 바)은 내용에서 쓴 것으로 표시한다. 그래서 안에 둔 `DdsCenteredColumn`이나 `imePadding()`이 그만큼을 다시 비우지 않는다. 예전 `DdsScaffold(topBar, navItems, …)`도 그대로 동작하지만 틀 안에 넣지 않는다(레일이 둘이 된다) | +| `DdsTopBar(title, leading, onBack, backEnabled, backDescription, columnMax, actions)` | 본문의 선에 선다. `onBack`을 주면 화살표를 그리고 선에 맞춘다. `leading`은 로고 자리(폭 최소 28). 둘 다 주면 화살표만. 화면이 본문 최대 폭을 바꾸면 `columnMax`에 같은 값 | +| `ddsBarNeedsTwoLines(areaWidth, titles, actions, reserve, columnMax)` | 틀이 하는 두 줄 판단. 틀을 쓰지 않는 앱용 | +| `DdsBodyColumn(modifier, scroll, max, spacing, contentPadding, paneTitle) { … }` | 상단 줄 아래 본문 전체. 스크롤되는 한 단으로 가운데에 놓이고, 네 방향 여백이 옆 여백 값이고, 블록 사이가 24. 화면이 들어올 때 밀려 들어온다(`ddsScreenContent(paneTitle)`가 붙어 있으니 다시 붙이지 않는다). `paneTitle`에 상단 줄 제목을 준다. 화면이 바뀔 때 화면 낭독기가 그 이름을 말한다(§8.1) | +| `Modifier.ddsBody(max)` | 본문을 직접 짜는 화면용. 폭을 제한하고 검사용 표시를 단다. 가운데 놓기와 여백은 화면이 한다 | +| `Modifier.ddsBodyLines(max)` | 본문은 아니지만 본문의 선에 서야 하는 것용: 진행 표시줄, 단계 표시, 아래쪽 버튼 줄. 폭과 옆 여백만 주고 표시는 달지 않는다 | +| `DdsCenteredColumn(modifier, max, paneTitle) { … }`, `DdsCenteredTitle(title, modifier, description)` | 한 가지 일만 하는 화면(본문 기둥처럼 `paneTitle`에 화면 이름을 준다). 묶음은 시스템 줄과 키보드가 남긴 자리의 가운데에 놓인다(입력칸이 키보드 위에 남는다). 옆은 창의 여백, 위·아래와 조각 사이는 24. 자리보다 길면 위에서 시작해 스크롤된다. 자리 전체에 페이지 바탕을 칠하고 묶음은 그 위로 밀려 들어온다. 제목은 가운데 정렬에 줄 길이가 고르고, 적어 넣은 줄바꿈을 지키고, 제목 하나로 읽힌다 | +| `DdsPaneList { … }`, `DdsPaneItem(label, selected, onClick, modifier, enabled)` | 영역 목록의 줄. 고른 줄은 레일에서 고른 항목처럼 보인다(옅은 브랜드 바탕, 브랜드 색, 굵게). 나머지는 레일과 같은 회색(`textSecondary`). 높이 48, 큰 글자 모드에서 56. 줄의 글자가 목록의 여백 선에 선다. "선택됨, 7개 중 2번째"로 읽힌다. 목록은 스크롤된다 | +| `DdsTestTags` | 부품이 스스로 달고 검사가 읽는 표시: `Body`, `TopBarTitle`, `Rail`, `ListPane`, `BottomTabs`, `Centered`, `CenteredArea` | +| `kr.devslab:dds-compose-testing` | `DdsFrameCheck`(§13.6) | + +레일과 하단 탭(틀 안이든 밖이든): 라벨은 두 줄까지 쓰고 항목도 그만큼 높아진다. 그래서 글자 200%에서도 +"거래처 매출"이 잘리지 않는다. 200%에서 세 줄이 필요한 라벨은 두 번째 줄 끝에서 말줄임표로 잘리고, 레일은 +80 그대로다. 항목이 다 들어가지 않으면 레일이 스크롤된다. + +**틀 없이 두 줄 상단 줄 쓰기.** `DdsTopBar`는 `ddsFrame().tallBar`를 읽는데, 이 값은 틀만 정한다. +`DdsAppFrame`을 쓰지 않는 앱은 `ddsBarNeedsTwoLines`로 판단하고, 틀이 하는 것처럼 화면들에 건넨다. + +```kotlin +val width = ddsFrame().width +val tall = ddsBarNeedsTwoLines(areaWidth = maxWidth, titles = everyBarTitle) // BoxWithConstraints 안에서 +CompositionLocalProvider(LocalDdsFrame provides DdsFrame(width, tallBar = tall)) { Screens() } +``` + +**고정 목록 쓰는 법.** 좁은 창에서 한 화면으로 여는 그 목록과 같은 것이다. 앱은 한 번만 짠다. 고정 목록 +옆에서 줄을 고르면 화면을 바꿔 끼운다(밀리지 않는다. `DdsMove.Tab`이 그렇게 한다). 그 화면에는 뒤로 +화살표가 없다(`ddsFrame().listPinned`로 안다). 뒤로 가기는 고른 줄을 되짚지 않고 영역을 나간다. 목록이 한 +화면일 때는 목록, 화면, 뒤로다. 목록 자기 상단 줄에는 로고도 화살표도 없어서 제목이 목록의 선에 선다. + +### 13.4 Navigation 3을 쓰는 앱 + +```kotlin +DdsAppFrame( + navItems = tabs, // List + selectedIndex = tabOf(top), + onSelect = { goToTab(it) }, + navigation = top !is SignIn && top !is Scanner, // 한 가지 일만 하는 화면 + barTitles = everyBarTitle, // 두 줄 판단용 + list = if (top in salesArea) ({ SalesList(chosen = top, onChoose = ::swapTo) }) else null, +) { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + transitionSpec = { ddsScreenChange(move, reduced) }, + popTransitionSpec = { ddsScreenChange(DdsMove.Pop, reduced) }, + predictivePopTransitionSpec = { ddsScreenChange(DdsMove.Pop, reduced) }, + entryProvider = { key -> NavEntry(key) { DdsScreenMotion(move) { Screen(key) } } }, + ) +} + +@Composable +fun HomeScreen() = DdsScaffold( + topBar = { DdsTopBar("홈", leading = { AppMark() }) { DdsIconButton(DdsIcons.Search, "검색", onClick = {}) } }, + bottomTabs = true, +) { padding -> + Box(Modifier.padding(padding)) { DdsBodyColumn(paneTitle = "홈") { /* 블록 */ } } +} + +@Composable +fun OrderScreen(onBack: () -> Unit) = DdsScaffold(topBar = { DdsTopBar("주문", onBack = onBack) }) { padding -> + Box(Modifier.padding(padding)) { DdsBodyColumn(paneTitle = "주문") { /* 블록 */ } } +} + +@Composable +fun SalesList(chosen: Any, onChoose: (Any) -> Unit) = DdsScaffold(topBar = { DdsTopBar("매출") }) { padding -> + Box(Modifier.padding(padding)) { + DdsPaneList { for (row in rows) DdsPaneItem(row.name, selected = row == chosen, onClick = { onChoose(row) }) } + } +} +``` + +`NavDisplay`는 화면 뒤에 아무것도 칠하지 않는다. `DdsScaffold`와 `DdsCenteredColumn`은 페이지 바탕을 +칠하므로, 이것들로 짠 화면은 바뀌는 동안 모든 프레임에서 아래 화면을 덮는다. 다른 것으로 짠 화면은 바탕을 +따로 줘야 한다. + +### 13.5 `DdsScreenHost`를 쓰는 앱 + +카탈로그(`android/catalog`의 `MainActivity.kt`와 `LayoutPages.kt`)가 통째로 그 예다. + +```kotlin +DdsAppFrame( + navItems = Page.entries.map { DdsNavItem(it.title, it.icon) }, + selectedIndex = page.ordinal, + onSelect = { index -> openTab(index) }, + navigation = !(page == Page.Layout && demo == LayoutDemo.WholeWindow), + barTitles = BarTitles, + list = if (page == Page.Layout) ({ LayoutPinnedList(demo ?: LayoutDemo.Body, choose) }) else null, +) { + val pinned = ddsFrame().listPinned + // 고정 목록 옆에는 늘 보여 줄 화면이 있다. 고르기 전까지는 첫 번째. + val shown = Shown(page, detail, if (page == Page.Layout) demo ?: (if (pinned) LayoutDemo.Body else null) else null) + DdsScreenHost(screen = shown, move = move) { (shownPage, shownDetail, shownDemo) -> + when { + shownDetail != null -> InnerScreen("Item ${shownDetail + 1}", goBack) { /* … */ } + shownDemo != null -> LayoutDemoScreen(shownDemo, onBack = if (pinned) null else goBack) + else -> TabScreen(shownPage) { /* … */ } // DdsScaffold(…, bottomTabs = true) + } + } +} +``` + +카탈로그의 Layout 탭이 부품을 보여 준다. 창에 대해 틀이 정한 것을 찍어 보이는 본문 기둥, 화살표와 아이콘 +둘이 있는 상단 줄, 입력칸과 버튼이 있는 로그인 모양의 가운데 묶음, 메뉴를 끈 화면. 실행 인자로 바로 연다. +`--es page layout --es demo body|bar|centered|wholewindow`, 들어간 화면은 `--es page motion --ei item 2`. + +### 13.6 앱이 자기 화면을 검사하기: `dds-compose-testing` + +```kotlin +androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") +``` + +`dds-compose`, Compose 테스트 규칙(`ui-test-junit4`, 버전은 이 꾸러미가 적은 Compose BOM이 정한다), +그리고 **Espresso 3.7.0**을 함께 가져온다. Compose BOM이 가져오는 Espresso(3.5.0)는 Android 17에서 돌지 않는다. +`createComposeRule()`을 쓰는 테스트는 그 규칙이 띄우는 빈 액티비티도 필요하다. 이것은 앱이 직접 더한다 +(`dds-compose`의 테스트도 그렇게 한다). + +```kotlin +debugImplementation("androidx.compose.ui:ui-test-manifest") // 버전은 앱의 Compose BOM이 정한다 +``` + +기기 테스트에서 앱의 실제 화면을 하나씩 열고 하나씩 본다. + +```kotlin +val check = DdsFrameCheck(composeRule) +for (window in listOf(DpSize(475.dp, 751.dp), DpSize(704.dp, 933.dp), DpSize(933.dp, 704.dp))) { + size = window // DeviceConfigurationOverride.ForcedSize + open(Home); check.look("Home at ${window.width}", DdsScreenKind.TopLevel) + open(Order); check.look("Order at ${window.width}", DdsScreenKind.Inner) + open(Sales); check.look("Sales at ${window.width}", DdsScreenKind.Listed) + open(Scan); check.look("Scan at ${window.width}", DdsScreenKind.WholeWindow) +} +check.assertSameLines() +``` + +`look`은 아래가 아니면 실패한다. 실패 문구는 화면 이름, 잰 값(dp), 맞게 해 주는 DDS 부품을 말한다. + +1. 본문이 화면에 정확히 하나 있다(`DdsBodyColumn`이나 `ddsBody()`, 또는 `DdsCenteredColumn` 하나). +2. 그 창에서 그 종류의 화면에 맞는 메뉴만 있다. + + | 종류 | 레일 | 고정 목록 | 하단 탭 | + | --- | --- | --- | --- | + | `TopLevel` | 600부터 | 없음 | 600 미만 | + | `Inner` | 600부터 | 없음 | 없음 | + | `Listed` | 600부터 | 840부터 | 없음 | + | `WholeWindow` | 없음 | 없음 | 없음 | + +3. 고정 목록은 레일이 끝나는 곳(1dp 선 뒤)에서 시작하고, 본문은 목록 뒤에서 시작한다. +4. 본문의 가운데가 레일과 목록이 남긴 자리의 가운데에서 1dp 안에 있다. +5. 화면의 제목이 본문의 양 끝 안에 있다. 고정 목록이 있으면 상단 줄이 둘인데, 목록 위의 제목은 목록의 + 것으로 보고 뺀다. + +`assertSameLines()`는 본 화면들을 종류, 고정 목록 유무, 본문인지 가운데 묶음인지, 창 폭으로 묶는다. 한 +묶음 안의 두 화면이 시작선이나 폭에서 1dp 넘게 다르면 둘 다 이름을 대며 실패한다. 화면은 바뀌는 중이 아니라 +멈춘 뒤에 본다. + +`look`이 돌려주는 `DdsScreenReading`은 앱이 증거로 남길 수 있다: `name`, `kind`, `window`, `windowWidthDp`, +`bodyLeftDp`, `bodyWidthDp`, `titleLeftDp`(상단 줄 제목이 없는 화면과 가운데 묶음은 null), `rail`, +`listPane`, `bottomTabs`, `centered`. `check.readings`에 다 모인다. + +### 13.7 확인한 것과 확인하지 못한 것 + +확인한 것: + +- 틀, 본문, 상단 줄, 가운데 묶음, 목록 줄, 검사 자체, 그리고 견본 앱 하나(탭 둘, 들어간 화면, 목록이 있는 + 영역, 메뉴를 끈 화면, 로그인 묶음)를 475×751, 704×933, 933×704에서 기기 테스트로 잰다. 제품이 정한 페이지 + 색에서도 쟀다. 새 테스트는 모두 실패하는 것을 먼저 보았다. 예외는 견본 앱 테스트의 두 부분이다 — 페이지 색 + 검사와 `assertSameLines` 호출은 실패하는 것을 본 적이 없다. 전체 기기 테스트는 실물 폰(SM-F971N, Android + 17)에서 돌렸다. 개수와 창 크기는 변경 기록에 있다. +- 화면이 바뀌는 동안, 시계를 멈추고 프레임마다: 레일은 자리도 픽셀도 그대로다. 탭 화면 위로 연 화면은 빈 + 프레임 없이 하단 탭을 덮는다. 가운데 묶음 화면은 모든 프레임에서 아래 화면을 페이지 바탕으로 덮는다. + `NavDisplay` 안에서도 그렇다. +- 글자 200%: 레일과 탭의 라벨, 제목이 잘리지 않는다. +- 오른쪽에서 왼쪽(테스트에서 설정): 레일과 목록이 오른쪽에 온다. +- 카탈로그를 창 세 가지로 고정한 에뮬레이터에서 보았다. 세 창 모두 밝은 화면, 933×704에서 어두운 화면, + 475×751에서 가장 큰 글자, 475×751에서 키보드를 연 채. 캡처마다 본문의 선을 그어 보았다. + +확인하지 못한 것: + +- **요청한 앱을 그 폰에서.** 정산나우가 로컬 빌드로 0.7.0에 옮기고 폰의 창 세 가지에서 확인한 뒤에 배포한다. +- 폰에서 카탈로그(에뮬레이터에서만 보았다). +- 틀과 함께 하는 뒤로 밀기(예측 뒤로 가기). +- 진짜 옆쪽 카메라 구멍. 구멍은 테스트 안에서만 설정했다. `DdsFrameCheck`가 옆 여백(시스템 줄·구멍)을 다루는 부분은 테스트가 없다. +- 오른쪽에서 왼쪽으로 쓰는 기기 언어, 그리고 그때의 카메라 구멍. +- 화면 분할: 앱 창이 화면보다 작을 때 틀 밖의 `ddsFrame()`이 읽는 폭. +- 따로 만든 앱의 빌드에서 이 꾸러미를 받아 쓰기, 그리고 릴리스 워크플로가 세 번째 꾸러미를 올리는지(올리는 + 단계가 꾸러미를 내는 모든 모듈을 덮지만 돌려 보지 않았다). +- TalkBack으로 듣기. + +알고도 그대로 둔 것: 상단 줄 끝의 글자 버튼은 끝선보다 12dp 나간다(끝에 12dp를 준다). 두 줄 판단은 시스템이 +알려 주는 옆쪽 구멍을 자리에서 뺀다. 그래서 틀 위의 무언가가 이미 그만큼 비켜 놓았다면 두 번 빠지고, 두 줄이 +몇 dp 일찍 된다. 좁은 상단 줄에 아이콘이 일곱 개 넘게 있으면 제목과 겹치는지 재지 않는다. Expanded 창에서 두 줄 판단은 고정 +목록이 보일 때는 그 옆에 남은 자리를, 아닐 때는 목록 없는 자리를 잰다. 그래서 긴 제목 하나가 목록 있는 +영역에서는 상단 줄을 높이고 다른 곳에서는 낮게 둘 수 있다. diff --git a/docs/mobile-native.md b/docs/mobile-native.md index d3f546ae..6e7e24f6 100644 --- a/docs/mobile-native.md +++ b/docs/mobile-native.md @@ -22,7 +22,8 @@ What the web rules do not cover, and still has to be decided, is collected in | Coordinate | Contents | | --- | --- | | `kr.devslab:dds-android-tokens` | Values only: Kotlin constants and XML resources. No Compose needed | -| `kr.devslab:dds-compose` | Theme, brand, motion and loading helpers. Brings the coordinate above | +| `kr.devslab:dds-compose` | Theme, brand, motion and loading helpers, components, the screen frame (§13). Brings the coordinate above | +| `kr.devslab:dds-compose-testing` | For an app's device tests only: `DdsFrameCheck` measures its screens against the frame (§13.6). Since 0.7.0 | Minimum Android 8 (API 26). Usage and versioning: [`android/README.md`](../android/README.md). @@ -664,8 +665,8 @@ Material3's `primary` is both a fill and a text color, where DDS has `bg.brand` | Button | `DdsControl.sm / md / lg` | 40 / **48** / 56 | §10, item 2 | | Text field | `DdsField.height` | 56 | §10, item 2 | | List row | `DdsRow.min / twoLine` | 56 / 72 | §10, item 2 | -| Top bar / bottom tabs | `DdsBar.top / bottom` | 64 / 80 | §10, item 2 | -| Screen gutter / between blocks | `DdsScreen.gutter / gutterWide / blockGap` | 16 / 24 (width 600+) / 24 | §10, item 2 | +| Top bar / bottom tabs / side rail / tall top bar | `DdsBar.top / bottom / rail / topTall` | 64 / 80 / 80 / 104 | §10, item 2 | +| Screen gutter / between blocks / wide screens | `DdsScreen.gutter / gutterWide / blockGap / breakpointExpanded / bodyMax / listPane / centeredMax` | 16 / 24 (width 600+) / 24 / 840 (list and detail side by side) / 680 (widest reading column) / 264 (list width) / 400 (widest centered form) | §10, item 2 | | Minimum touch | `DdsTouch.min / large` | 48 / 56 | §9 | - **There is one button height, 48.** `lg` (56) is for the single primary button at the bottom of a screen. @@ -776,7 +777,7 @@ in for now"; making them DDS rules is the owner's call. Once decided, | # | Missing rule | Value put in for now | Basis | | --- | --- | --- | --- | | 1 | How the other five (pressed, pale background, brand text, focus, the dark values) follow from one main color | none — as on the web, the product decides. A value not written stays the DDS default | the web only has the asklinq and booklinq examples. JeongsanNow's main color is in the DDS hue family, so one line is enough | -| 2 | Control heights, text field, list row, bar heights and screen gutter on a touch screen | 40, 48, 56 / 56 / 56, 72 / 64, 80 / 16, 24 | web buttons are 32, 40, 48. The three apps and Material3 defaults | +| 2 | Control heights, text field, list row, bar heights and screen gutter on a touch screen | 40, 48, 56 / 56 / 56, 72 / 64, 80 / 16, 24 | web buttons are 32, 40, 48. The three apps and Material3 defaults. **The gutter is decided (2026-11-12, D-052): 16 under a 600dp window, 24 from 600, by the window's width (§13)** | | 3 | 150ms, the 30ms × 6 stagger, the 24dp shift, the `Travel` easing, the splash 150ms and 1s | JeongsanNow's settled values | the web tokens only have 100, 200, 300 and three easings | | 4 | Whether a placeholder moves | still | the web pulses at 1600ms. JeongsanNow and Qooroo are still | | 5 | Card shadow | none, edge only | the web uses `elevation.1`. None of the three apps uses a shadow | @@ -850,7 +851,8 @@ all of them (the Parts tab); the web counterpart is named where there is one. | `DdsCard`, `DdsSection`, `DdsDivider`, `DdsListRow` | `bg.elevated` + 1dp edge + radius 12, no shadow; a section title is a heading; a row is 56 or 72 | Card | | `DdsNotice`, `DdsBadge`, `DdsChip` | each meaning has its own icon and color pair; a badge repeats the state with a dot; a neutral one gets an edge | Alert, Badge, Tag | | `DdsEmptyState`, `DdsSpinner` | a dashed outline, a title, one sentence, at most one action | EmptyState, Spinner | -| `DdsTopBar`, `DdsBottomBar`, `DdsNavRail`, `DdsScaffold` | bars are fixed and set apart by a 1dp line; tabs below 600dp, a rail from 600dp; selection changes pill, color and weight together | — (mobile only) | +| `DdsTopBar`, `DdsBottomBar`, `DdsNavRail`, `DdsScaffold` | bars are fixed and set apart by a 1dp line; tabs below 600dp, a rail from 600dp; selection changes pill, color and weight together. From 0.7.0 the top bar stands on the body's lines and labels take two lines (§13) | — (mobile only) | +| `DdsAppFrame`, `DdsBodyColumn`, `DdsCenteredColumn`, `DdsCenteredTitle`, `DdsPaneList`, `DdsPaneItem` (0.7.0) | the screen frame: the window decides the menus, one body column per screen, a centered block for a screen with one thing to do, the rows of a pinned list (§13) | — (mobile only) | | `DdsDialog` | confirms a decision; radius 12, `bg.elevated` | Dialog | Rules that came out of reading the captures (light, dark, 200% font, large text, wide): @@ -916,3 +918,286 @@ screen (white on an orange face, a white card on a paper page, the four buttons, end-aligned menu). Not yet built: date and time pickers. Use the Material3 ones under `DdsTheme`. + +## 13. Screen frame (0.7.0, D-052) + +Every screen of an app stands on one start line, and the top bar stands on the same +lines as the body. JeongsanNow found, on an unfolded phone, screens starting at 187, +147 and 127dp, a rail that was there on tab screens only, and a top bar that started at +the window's edge while the body started in the middle. The cause was that DDS had the +numbers but no part that kept them, so each screen wrote its own widths. 0.7.0 adds +those parts. Use them and the lines come out right; no screen measures anything. + +### 13.1 The rules + +1. **The window's width decides the menus.** Under 600dp: bottom tabs on the top-level + screens. From 600dp: a rail. From 840dp: a rail, and beside it the area's list when + the screen belongs to an area that has one. A screen never measures the window; it + reads what the frame decided (`ddsFrame()`). +2. **The gutter comes from the window, not from the room left.** 16dp under 600, 24dp + from 600. A screen beside a pinned list has only 587dp, but the window is wide, so it + uses 24: the top bar, the body and the list all use the gutter of their window. (This + settles the gutter in §10, item 2.) +3. **One body column per screen**, no wider than 680dp with its gutters, in the middle of + what the rail and the list leave. +4. **The top bar stands on the body's lines.** Its content is as wide as the body and + sits in the same place. The back arrow's 24dp icon starts on the start line, the last + icon's 24dp icon ends on the end line, and each icon's 48dp touch area reaches 12dp into + the gutter. The title starts 40dp after the start line, after a mark or after the + arrow, so it does not move sideways when a screen opens. With neither, it starts on the + line. +5. **The rail is drawn by the frame, the bottom tabs by the screen.** The rail is the same + on every screen and must not move while the screen beside it changes. The bottom tabs + belong to the top-level screens: a detail screen that opens must cover them as it + arrives, which it can only do if they are part of the screen under it (D-051). +6. **A screen with one thing to do** (signing in, unlocking) is one block, no wider than + 400dp, in the very middle of what it is given. +7. **The top bar's height belongs to the window.** If any title the app names does not + fit one line at the current font size, every top bar of the window is 104dp with two + lines, so the body does not jump up and down from screen to screen. + +### 13.2 The numbers + +| What | Value | Token | +| --- | --- | --- | +| Medium window from / Expanded window from | 600 / 840 | `DdsScreen.breakpoint` / `breakpointExpanded` | +| Gutter, under 600 / from 600 | 16 / 24 | `DdsScreen.gutter` / `gutterWide`, or `DdsWindowWidth.gutter` | +| Body column, widest (gutters included) | 680 | `DdsScreen.bodyMax` | +| Pinned list | 264, and a 1dp line after it | `DdsScreen.listPane` | +| Centered block, widest | 400 | `DdsScreen.centeredMax` | +| Rail | 80 | `DdsBar.rail` | +| Top bar / with two-line titles | 64 / 104 | `DdsBar.top` / `topTall` | +| Title start | start line + 40 | — | +| Icon button / its icon | 48 / 24, hanging 12 into the gutter | `DdsTouch.min`, `DdsIcon.md` | +| A mark in front of the title | at least 28 wide, then 12 to the title | — | + +What that gives on the three windows of the SM-F971N (measured in the catalog, on an +emulator forced to these sizes, and in the device tests): + +| Window | Menus | Body's text lines (dp from the window's start) | +| --- | --- | --- | +| 475×751 (folded) | bottom tabs on the top-level screens | 16 and 459 | +| 704×933 (unfolded, upright) | rail (80 + 1dp line) | 105 and 680 | +| 933×704 (unfolded, on its side) | rail | 191 and 823 | +| 933×704, in an area with a list | rail, list 81 to 345, 1dp line | list text at 105; body 370 and 909 | + +### 13.3 The parts + +| Part | What it does | +| --- | --- | +| `DdsWindowWidth`, `ddsWindowWidth(width)`, `DdsWindowWidth.gutter` | Compact / Medium / Expanded at 600 and 840; the gutter of each | +| `DdsAppFrame(navItems, selectedIndex, onSelect, navigation, barTitles, barActions, barReserve, list) { … }` | Once per app, outside the screen switcher (a `NavDisplay` or a `DdsScreenHost`). Draws the rail and the pinned list; keeps the content where it is when they appear or go (on a fold or a rotation the scroll position and typed text stay); keeps screens clear of a cutout at the sides; mirrors in right-to-left. `navigation = false` turns every menu off (before sign-in, a scanner, a writing screen). Its background is `bgDefault` | +| `DdsFrame`, `LocalDdsFrame`, `ddsFrame()` | What the frame decided: `width`, `rail`, `listPinned`, `tallBar`. Outside a frame `ddsFrame()` has only `width`, from the window | +| `DdsScaffold(topBar, modifier, bottomTabs) { padding -> … }` | One screen inside the frame. `bottomTabs = true` on top-level screens: the tabs show on a Compact window only, with the items and selection the frame was given. The system insets its `padding` already covers (the status bar under the top bar, the navigation bar under the content) are marked used for the content, so a `DdsCenteredColumn` or an `imePadding()` inside does not pad them again. The old `DdsScaffold(topBar, navItems, …)` still works; do not put it inside a frame (two rails) | +| `DdsTopBar(title, leading, onBack, backEnabled, backDescription, columnMax, actions)` | Stands on the body's lines. Pass `onBack` and it draws the arrow and lines it up; a `leading` is a mark (at least 28 wide). With both, only the arrow. `columnMax` is the body's widest, if a screen changes it | +| `ddsBarNeedsTwoLines(areaWidth, titles, actions, reserve, columnMax)` | The frame's two-line decision, for an app without a frame | +| `DdsBodyColumn(modifier, scroll, max, spacing, contentPadding, paneTitle) { … }` | The whole body under the top bar: one scrolling column, centered, the gutter on all four sides, 24 between blocks. It slides when the screen arrives (`ddsScreenContent(paneTitle)` is applied; do not add it again). Pass the top bar's title as `paneTitle`: a screen reader says it when the screen changes (§8.1) | +| `Modifier.ddsBody(max)` | For a screen that lays its body out itself: limits the width and tags it for the check. Centering and padding are the screen's | +| `Modifier.ddsBodyLines(max)` | For something that is not the body but stands on its lines: a progress bar, a step bar, a row of buttons at the bottom. Width and gutter, no tag | +| `DdsCenteredColumn(modifier, max, paneTitle) { … }`, `DdsCenteredTitle(title, modifier, description)` | A screen with one thing to do (give it the screen's name as `paneTitle`, as a body column). The block is centered in what the bars and the keyboard leave (a field stays above the keyboard), with the window's gutter at its sides and 24 above, below and between its pieces; taller than the room, it starts at the top and scrolls. It paints the page behind its whole area and slides in over it. The title is centered with lines of even length, keeps the line breaks written into it, and is read as one heading | +| `DdsPaneList { … }`, `DdsPaneItem(label, selected, onClick, modifier, enabled)` | The rows of an area's list. A chosen row looks like a chosen rail item (pale brand face, brand color, heavier); the others are in the rail's grey (`textSecondary`). 48 high, 56 in large text mode. The row's text stands on the list's gutter line. Read as "selected, 2 of 7". The list scrolls | +| `DdsTestTags` | The tags the parts put on themselves and the check reads: `Body`, `TopBarTitle`, `Rail`, `ListPane`, `BottomTabs`, `Centered`, `CenteredArea` | +| `kr.devslab:dds-compose-testing` | `DdsFrameCheck` (§13.6) | + +The rail and the bottom tabs (inside a frame or not): labels take up to two lines and the +item grows with them, so "거래처 매출" at 200% font is not cut. A label that needs three +lines at 200% is cut with an ellipsis after the second; the rail stays 80. The rail +scrolls when its items do not fit. + +**The tall bar without a frame.** `DdsTopBar` reads `ddsFrame().tallBar`, which only a frame +sets. An app without `DdsAppFrame` makes the decision with `ddsBarNeedsTwoLines` and hands it +to its screens the way the frame does: + +```kotlin +val width = ddsFrame().width +val tall = ddsBarNeedsTwoLines(areaWidth = maxWidth, titles = everyBarTitle) // in a BoxWithConstraints +CompositionLocalProvider(LocalDdsFrame provides DdsFrame(width, tallBar = tall)) { Screens() } +``` + +**How the pinned list is used.** It is the same list that is a screen of its own on a +narrower window; the app writes it once. Beside the pinned list, choosing a row swaps the +screen (nothing slides; `DdsMove.Tab` does that), the screen has no back arrow +(`ddsFrame().listPinned` tells it), and back leaves the area instead of going back through +the rows chosen. As a screen of its own it goes list, screen, back. The list's own top bar +has no mark and no arrow, so its title stands on the list's line. + +### 13.4 An app with Navigation 3 + +```kotlin +DdsAppFrame( + navItems = tabs, // List + selectedIndex = tabOf(top), + onSelect = { goToTab(it) }, + navigation = top !is SignIn && top !is Scanner, // screens with one thing to do + barTitles = everyBarTitle, // for the two-line decision + list = if (top in salesArea) ({ SalesList(chosen = top, onChoose = ::swapTo) }) else null, +) { + NavDisplay( + backStack = backStack, + onBack = { backStack.removeLastOrNull() }, + transitionSpec = { ddsScreenChange(move, reduced) }, + popTransitionSpec = { ddsScreenChange(DdsMove.Pop, reduced) }, + predictivePopTransitionSpec = { ddsScreenChange(DdsMove.Pop, reduced) }, + entryProvider = { key -> NavEntry(key) { DdsScreenMotion(move) { Screen(key) } } }, + ) +} + +@Composable +fun HomeScreen() = DdsScaffold( + topBar = { DdsTopBar("홈", leading = { AppMark() }) { DdsIconButton(DdsIcons.Search, "검색", onClick = {}) } }, + bottomTabs = true, +) { padding -> + Box(Modifier.padding(padding)) { DdsBodyColumn(paneTitle = "홈") { /* blocks */ } } +} + +@Composable +fun OrderScreen(onBack: () -> Unit) = DdsScaffold(topBar = { DdsTopBar("주문", onBack = onBack) }) { padding -> + Box(Modifier.padding(padding)) { DdsBodyColumn(paneTitle = "주문") { /* blocks */ } } +} + +@Composable +fun SalesList(chosen: Any, onChoose: (Any) -> Unit) = DdsScaffold(topBar = { DdsTopBar("매출") }) { padding -> + Box(Modifier.padding(padding)) { + DdsPaneList { for (row in rows) DdsPaneItem(row.name, selected = row == chosen, onClick = { onChoose(row) }) } + } +} +``` + +`NavDisplay` paints nothing behind its entries. `DdsScaffold` and `DdsCenteredColumn` +paint the page, so a screen built from them covers the one under it on every frame of a +change. A screen built from something else needs a background of its own. + +### 13.5 An app with `DdsScreenHost` + +The catalog (`android/catalog`, `MainActivity.kt` and `LayoutPages.kt`) is a whole one: + +```kotlin +DdsAppFrame( + navItems = Page.entries.map { DdsNavItem(it.title, it.icon) }, + selectedIndex = page.ordinal, + onSelect = { index -> openTab(index) }, + navigation = !(page == Page.Layout && demo == LayoutDemo.WholeWindow), + barTitles = BarTitles, + list = if (page == Page.Layout) ({ LayoutPinnedList(demo ?: LayoutDemo.Body, choose) }) else null, +) { + val pinned = ddsFrame().listPinned + // Beside the pinned list there is always a screen to show: the first, until one is chosen. + val shown = Shown(page, detail, if (page == Page.Layout) demo ?: (if (pinned) LayoutDemo.Body else null) else null) + DdsScreenHost(screen = shown, move = move) { (shownPage, shownDetail, shownDemo) -> + when { + shownDetail != null -> InnerScreen("Item ${shownDetail + 1}", goBack) { /* … */ } + shownDemo != null -> LayoutDemoScreen(shownDemo, onBack = if (pinned) null else goBack) + else -> TabScreen(shownPage) { /* … */ } // DdsScaffold(…, bottomTabs = true) + } + } +} +``` + +The catalog's Layout tab shows the parts: a body column that prints what the frame decided +for the window, a top bar with an arrow and two icons, a sign-in-like centered block with a +field and a button, and a screen with the menus off. Launch extras open them directly: +`--es page layout --es demo body|bar|centered|wholewindow`, and `--es page motion --ei item 2` +for an inner screen. + +### 13.6 Checking an app's own screens: `dds-compose-testing` + +```kotlin +androidTestImplementation("kr.devslab:dds-compose-testing:0.7.0") +``` + +It brings `dds-compose`, the Compose test rule (`ui-test-junit4`, versioned by the Compose +BOM it names) and **Espresso 3.7.0**: the Espresso the Compose BOM brings (3.5.0) does not +run on Android 17. A test that uses `createComposeRule()` also needs the empty activity it +starts, which the app adds itself, as `dds-compose`'s own tests do: + +```kotlin +debugImplementation("androidx.compose.ui:ui-test-manifest") // versioned by the app's Compose BOM +``` + +In a device test, open the app's real screens one by one and look at each: + +```kotlin +val check = DdsFrameCheck(composeRule) +for (window in listOf(DpSize(475.dp, 751.dp), DpSize(704.dp, 933.dp), DpSize(933.dp, 704.dp))) { + size = window // DeviceConfigurationOverride.ForcedSize + open(Home); check.look("Home at ${window.width}", DdsScreenKind.TopLevel) + open(Order); check.look("Order at ${window.width}", DdsScreenKind.Inner) + open(Sales); check.look("Sales at ${window.width}", DdsScreenKind.Listed) + open(Scan); check.look("Scan at ${window.width}", DdsScreenKind.WholeWindow) +} +check.assertSameLines() +``` + +`look` fails, naming the screen, the measured numbers in dp and the DDS part that gives +the right result, unless: + +1. there is exactly one body on screen (a `DdsBodyColumn` or `ddsBody()`, or one + `DdsCenteredColumn`); +2. the menus are the ones for that kind of screen on that window: + + | Kind | Rail | Pinned list | Bottom tabs | + | --- | --- | --- | --- | + | `TopLevel` | from 600 | no | under 600 | + | `Inner` | from 600 | no | no | + | `Listed` | from 600 | from 840 | no | + | `WholeWindow` | no | no | no | + +3. a pinned list starts where the rail ends (after its 1dp line), and the body starts + after the list; +4. the body's middle is within 1dp of the middle of what the rail and the list leave; +5. the screen's title is inside the body's edges. With a pinned list there are two top + bars; the titles over the list are the list's and are left out. + +`assertSameLines()` then groups what was looked at by kind, pinned list or not, body or +centered block, and window width, and fails if two screens of a group start more than 1dp +apart or differ in width by more than 1dp, naming both. Look at a screen at rest, not +during a change. + +`look` returns a `DdsScreenReading` an app can keep as evidence: `name`, `kind`, `window`, +`windowWidthDp`, `bodyLeftDp`, `bodyWidthDp`, `titleLeftDp` (null when the screen has no +top bar title, and for a centered block), `rail`, `listPane`, `bottomTabs`, `centered`. +`check.readings` holds all of them. + +### 13.7 What was checked, and what was not + +Checked: + +- Device tests of the frame, the body, the top bar, the centered block, the list rows, + the check itself, and a whole sample app (two tabs, an inner screen, an area with a list, + a whole-window screen, a sign-in block) at 475×751, 704×933 and 933×704, also under a + product's own page color. Every new test was first seen failing, except two parts of the + sample-app test: its page-color assertions and its `assertSameLines` call were never seen + failing. The whole suite ran on + the phone (SM-F971N, Android 17); the count and the window are in the changelog. +- During a change of screen, with the clock held, frame by frame: the rail keeps its place + and every pixel; a screen opened over a tab screen covers the bottom tabs with no empty + frame; a centered screen covers the screen under it with the page on every frame, also + in a `NavDisplay`. +- At twice the font size: no label of the rail or the tabs and no title is cut. +- Right-to-left (set in the tests): the rail and the list are on the right. +- The catalog on an emulator forced to the three windows: light at all three, dark at + 933×704, the largest font at 475×751, the keyboard open at 475×751. The body's lines were + drawn over each capture. + +Not checked: + +- **The app that asked for it, on its phone.** JeongsanNow moves onto 0.7.0 from a local + build and checks the three windows on the phone before the release. +- The catalog on the phone (it was looked at on the emulator only). +- The back swipe (predictive back) with the frame. +- A real cutout at the side: cutouts were set in the tests only. `DdsFrameCheck`'s handling + of side insets has no test. +- A right-to-left device language, and a cutout in right-to-left. +- Split screen: what `ddsFrame()` reads outside a frame when the app's window is smaller + than the screen. +- The artifact resolved from a separate app's build, and the release workflow publishing + the third artifact (its publish step covers every module that publishes; not run). +- Listening with TalkBack. + +Known and left as is: a text button at the end of a top bar hangs 12dp past the end line +(give it 12dp at its end); the two-line decision takes the side cutouts off the room as +the system reports them, so if something above the frame has already made room for them +they are taken off twice and two lines come a few dp early; seven or more icons on a +narrow top bar are not measured against the title. At Expanded the two-line decision measures +the room beside the pinned list when the list is shown and the room without it otherwise, so a +long title can make the bar tall in an area with a list and short elsewhere. diff --git a/docs/superpowers/plans/2026-11-12-dds-android-screen-frame.md b/docs/superpowers/plans/2026-11-12-dds-android-screen-frame.md new file mode 100644 index 00000000..e4b41b19 --- /dev/null +++ b/docs/superpowers/plans/2026-11-12-dds-android-screen-frame.md @@ -0,0 +1,349 @@ +# DDS Android Screen Frame Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give DDS Android the parts that make every screen keep one start line: a window-width frame (rail, pinned list, bottom tabs), a body column, a top bar on the body's lines, a centered block for single-task screens, and a check an app can run on its own screens. + +**Architecture:** `DdsAppFrame` sits once outside the screen switcher and draws the rail and the pinned list; it tells screens what it drew through `LocalDdsFrame`. Screens draw their own top bar and (top-level screens, compact windows) the bottom tabs through a new `DdsScaffold` overload. Layout parts read the window gutter from the frame, never measure for themselves. A new artifact `dds-compose-testing` measures tagged nodes. + +**Tech Stack:** Kotlin 2.2.20, Jetpack Compose (BOM 2026.01.00), Material3, Gradle (AGP 8.13), vanniktech maven-publish 0.34.0, Compose UI test (`DeviceConfigurationOverride.ForcedSize`), Node token generator. + +**Spec:** `docs/superpowers/specs/2026-11-12-dds-android-screen-frame-design.ko.md` (decision record: `docs/decisions.md` D-052). Read both before starting. + +## Global Constraints + +- Branch `feat/android-screen-frame`. Run `git branch --show-current` before every commit and push. Stage files by name; never `git add -A`; never `git stash`. +- No AI attribution in commits, PR text or docs. +- No hand-written color or size literals in Kotlin (`scripts/check-android-sources.mjs`); tests use `Color.Red/Blue/Green`. +- Numbers, verbatim from the spec: breakpoints 600 and 840; gutter 16 (Compact) / 24 (Medium, Expanded); body max 680 (gutters included); list pane 264 plus a 1dp line; centered max 400; rail 80; top bar 64, tall 104; title starts at gutter + 40; icon button 48 with a 24 glyph, hanging 12 into the gutter; leading slot at least 28, then 12. +- `explicitApi()` is on: every public declaration needs `public` and KDoc in plain English. +- Device tests run only on the dedicated emulator: `ANDROID_SERIAL=emulator-5590` (AVD `dds_catalog_api36`, port 5590). Never touch `emulator-5554`, `emulator-5570` or the physical phone. Shut the emulator down when done (`adb -s emulator-5590 emu kill`). +- Every new test is seen failing for the right reason before the code that makes it pass. +- Before each commit: `./gradlew testDebugUnitTest lintDebug assembleDebug` in `android/`, and from the repo root `pnpm run verify` and `pnpm run verify:source:stage1-2`; read the output. Device tests: full `:dds-compose:connectedDebugAndroidTest`, and read the result XML for `tests/failures/skipped`. +- Docs are pairs: `*.md` and `*.ko.md` change together. Korean docs in plain words. +- Do not modify JeongsanNow or Qooroo. Do not publish: Maven release waits for JeongsanNow's check on a physical phone. + +## Review Focus + +- A long tab label at 200% font: the rail and the bottom tabs must show it on two lines, not cut it. (Task 4) +- A window that changes class while a screen holds state (unfold, rotate): scroll position and typed text must survive. (Task 5) +- A top bar with no leading and no actions, and one with a 48dp button passed as `leading` by existing code: neither may overlap or break. (Task 7) +- A centered block taller than the window at large text: it must start at the top and scroll, not be cut at both ends. (Task 8) +- Two top bars on screen at once (pinned list and screen): the check must read the screen's title, not the list's. (Task 9) + +--- + +### Task 1: Prove the three assumptions the design rests on + +**Files:** +- Create (throwaway, not committed): `android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/ProbeTest.kt` +- Modify: `docs/superpowers/specs/2026-11-12-dds-android-screen-frame-design.ko.md` (§13, record the answers) + +**Interfaces:** +- Produces: three recorded facts later tasks rely on — (a) whether Material `NavigationBar`/`NavigationRailItem` grow for a two-line label, (b) whether `DeviceConfigurationOverride(DeviceConfigurationOverride.ForcedSize(DpSize(933.dp, 704.dp)))` gives `BoxWithConstraints.maxWidth == 933.dp` on the phone-sized emulator, (c) how `LocalWindowInfo.current.containerSize` reads under that override. + +- [ ] **Step 1:** Write `ProbeTest` with three tests printing measured values with `println` and asserting: under `ForcedSize(DpSize(933.dp, 704.dp))` a `BoxWithConstraints` reports `maxWidth` within 1dp of 933; a `NavigationBar` holding an item whose label is two lines at `fontScale = 2f` (`DeviceConfigurationOverride.FontScale(2f)`) is taller than 80dp and the label node's bounds are inside the bar; the same for `NavigationRailItem`. +- [ ] **Step 2:** Run `ANDROID_SERIAL=emulator-5590 ./gradlew :dds-compose:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=kr.devslab.dds.compose.ProbeTest`. Read the output. +- [ ] **Step 3:** Write the three answers into spec §13. If (a) fails, Task 4 draws the tab row and rail items as DDS's own layout (as `DdsTabs` does in `Rows.kt`) instead of wrapping Material's; say so in §13. If (b) fails, device tests size the frame with `Modifier.requiredSize` inside a horizontally scrollable host and read `positionInRoot`/`size` instead; say so. +- [ ] **Step 4:** Delete `ProbeTest.kt`. Commit the spec change only: `docs(android): what the frame design assumed, checked on a device`. + +### Task 2: Tokens + +**Files:** +- Modify: `tokens/native.json` (`screen` and `bar` groups), `scripts/generate-android-tokens.mjs` (group descriptions only, if the text no longer fits) +- Regenerate: `android/dds-tokens/src/main/kotlin/kr/devslab/dds/tokens/DdsDimens.kt`, the generated `dimens` XML +- Test: `tests/android-tokens.test.mjs` + +**Interfaces:** +- Produces: `DdsScreen.breakpointExpanded = 840`, `DdsScreen.bodyMax = 680`, `DdsScreen.listPane = 264`, `DdsScreen.centeredMax = 400`, `DdsBar.rail = 80`, `DdsBar.topTall = 104` (all `const val Int`), and the matching `@dimen/dds_screen_*`, `@dimen/dds_bar_*`. + +- [ ] **Step 1:** Add a test to `tests/android-tokens.test.mjs` asserting the generated Kotlin contains the six constants with these values. Run `node --test tests/android-tokens.test.mjs`; expect it to fail on the missing constants. +- [ ] **Step 2:** Add to `tokens/native.json`: `screen.breakpoint-expanded` 840, `screen.body-max` 680, `screen.list-pane` 264, `screen.centered-max` 400, `bar.rail` 80, `bar.top-tall` 104 (`$type: dimension`); extend the two `$description`s. +- [ ] **Step 3:** Run `node scripts/generate-android-tokens.mjs`, then `node scripts/generate-android-tokens.mjs --check` and the test; expect pass. +- [ ] **Step 4:** Update the number table in `docs/mobile-native.md` and `.ko.md` (the row "화면 여백 / 블록 사이") with the new values so `pnpm run verify`'s doc sync passes. Run the full pre-commit set. Commit: `feat(android): numbers for the screen frame (840, 680, 264, 400, rail 80, tall bar 104)`. + +### Task 3: Window width, the frame's report, test tags + +**Files:** +- Create: `android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/WindowWidth.kt`, `.../layout/DdsFrame.kt`, `.../layout/DdsTestTags.kt` +- Test: `android/dds-compose/src/test/kotlin/kr/devslab/dds/compose/layout/WindowWidthTest.kt` + +**Interfaces:** +- Produces: + - `public enum class DdsWindowWidth { Compact, Medium, Expanded }` + - `public fun ddsWindowWidth(width: Dp): DdsWindowWidth` + - `public val DdsWindowWidth.gutter: Dp` + - `@Immutable public class DdsFrame(public val width: DdsWindowWidth, public val rail: Boolean = false, public val listPinned: Boolean = false, public val tallBar: Boolean = false)` with `equals`/`hashCode` + - `public val LocalDdsFrame: ProvidableCompositionLocal` (default `null`) + - `@Composable public fun ddsFrame(): DdsFrame` — `LocalDdsFrame.current`, else a frame with only `width`, taken from the window (use whichever source Task 1 (c) showed is right) + - `internal class FrameNav(val items: List, val selectedIndex: Int, val onSelect: (Int) -> Unit)` and `internal val LocalFrameNav: ProvidableCompositionLocal` + - `public object DdsTestTags { const val Body = "dds-body"; TopBarTitle = "dds-top-bar-title"; Rail = "dds-rail"; ListPane = "dds-list-pane"; BottomTabs = "dds-bottom-tabs"; Centered = "dds-centered"; CenteredArea = "dds-centered-area" }` + +- [ ] **Step 1:** Write `WindowWidthTest`: + +```kotlin +@Test fun theTwoLinesBetweenTheThreeWidths() { + assertEquals(DdsWindowWidth.Compact, ddsWindowWidth(599.dp)) + assertEquals(DdsWindowWidth.Medium, ddsWindowWidth(600.dp)) + assertEquals(DdsWindowWidth.Medium, ddsWindowWidth(839.dp)) + assertEquals(DdsWindowWidth.Expanded, ddsWindowWidth(840.dp)) +} +@Test fun theGutterFollowsTheWindow() { + assertEquals(16.dp, DdsWindowWidth.Compact.gutter) + assertEquals(24.dp, DdsWindowWidth.Medium.gutter) + assertEquals(24.dp, DdsWindowWidth.Expanded.gutter) +} +``` + +- [ ] **Step 2:** Run `./gradlew :dds-compose:testDebugUnitTest --tests "*WindowWidthTest"`; expect a compile failure (nothing exists yet). +- [ ] **Step 3:** Implement the three files from the Interfaces block. Values come from `DdsScreen`, not literals. +- [ ] **Step 4:** Run the test; expect pass. Pre-commit set. Commit: `feat(android): window width classes and what the frame tells a screen`. + +### Task 4: Rail and bottom tabs at large text + +**Files:** +- Modify: `android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/component/Shell.kt` (`DdsBottomBar`, `DdsNavRail`, `NavLabel`) +- Test: create `android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameOnDeviceTest.kt` + +**Interfaces:** +- Consumes: `DdsBar.rail`, `DdsTestTags.Rail`, `DdsTestTags.BottomTabs`. +- Produces: `DdsBottomBar` and `DdsNavRail` keep their signatures. Labels wrap to two lines, centered; an item and the bar grow to hold them; the rail is `DdsBar.rail` wide and scrolls vertically when its items do not fit; both carry their test tag. + +- [ ] **Step 1:** In `FrameOnDeviceTest` write `aLongLabelIsShownOnTwoLinesNotCut` (under `DeviceConfigurationOverride.FontScale(2f)`, a bar and a rail with the label "거래처 매출 관리": the label node's text layout has `lineCount == 2`, `hasVisualOverflow == false`, and its bounds lie inside the bar/rail) and `theRailScrollsWhenItsItemsDoNotFit` (a rail 200dp tall with six items: the last item can be brought on screen with `performScrollTo()` and is then displayed) and `theRailIsItsTokenWide` (80dp). +- [ ] **Step 2:** Run the class on the emulator; expect the first and third to fail (one line with ellipsis; width not pinned), the second to fail (no scroll). +- [ ] **Step 3:** Change `NavLabel` to `maxLines = 2`, `softWrap = true`, `textAlign = Center`; give the items and bars a minimum height that holds two label lines at the current font (the route Task 1 (a) chose); pin the rail width; add `verticalScroll` to the rail; add the tags. Update the KDoc that says a label is cut with an ellipsis. +- [ ] **Step 4:** Run the class; expect pass. Run the whole device suite (40 existing + these) and read the XML. Pre-commit set. Commit: `feat(android): a tab label takes two lines at large sizes; the rail scrolls`. + +### Task 5: `DdsAppFrame` and the per-screen `DdsScaffold` + +**Files:** +- Create: `android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/AppFrame.kt` +- Modify: `.../component/Shell.kt` (add the `DdsScaffold` overload; the existing one stays) +- Test: `FrameOnDeviceTest.kt` + +**Interfaces:** +- Consumes: Task 3 types, `DdsNavRail`, `DdsBottomBar`, `DdsScreen.listPane`, `DdsBorder.hairline`. +- Produces: + +```kotlin +@Composable +public fun DdsAppFrame( + navItems: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + navigation: Boolean = true, + barTitles: List = emptyList(), + barActions: Int = 2, + barReserve: (title: String) -> Dp = { 0.dp }, + list: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) + +@Composable +public fun DdsScaffold( + topBar: @Composable () -> Unit, + modifier: Modifier = Modifier, + bottomTabs: Boolean = false, + content: @Composable (PaddingValues) -> Unit, +) +``` + + Rules: `rail = navigation && width != Compact && navItems.isNotEmpty()`; `listPinned = navigation && width == Expanded && list != null`; the list slot is `DdsScreen.listPane` wide, tagged `DdsTestTags.ListPane`, followed by a 1dp `borderDefault` line; `content()` is called from one fixed place so its state survives the rail and list coming and going; horizontal safe insets: the rail takes the start side, the content area the end side (both when there is no rail and no list); `tallBar` is `false` in this task (Task 7 computes it). The new `DdsScaffold` draws `DdsBottomBar` from `LocalFrameNav` only when `bottomTabs && ddsFrame().width == Compact && LocalFrameNav.current != null`. + +- [ ] **Step 1:** Add to `FrameOnDeviceTest` a helper `frame(size: DpSize, navigation: Boolean = true, list: Boolean = false, topLevel: Boolean = true)` that mounts `DdsAppFrame` (four items) around a `DdsScaffold(bottomTabs = topLevel)` under the forced size, and these tests, each asserting on the tags: + - `aNarrowWindowHasBottomTabsOnATopLevelScreenOnly` (475×751: tabs when `topLevel`, none otherwise, never a rail) + - `aMediumWindowHasTheRailAndNoList` (704×933 with `list = true`: rail, no list pane, no tabs) + - `aWideWindowHasTheRailAndTheListBesideIt` (933×704 with `list = true`: rail at x 0..80, list pane at 80..344, a 1dp line, content from 345) + - `withNavigationOffNothingIsDrawnAtAnyWidth` (three sizes) + - `aScreenReadsWhatTheFrameDrew` (`ddsFrame()` inside content equals `DdsFrame(Expanded, rail = true, listPinned = true)`) + - `whatAScreenHoldsSurvivesTheWindowChangingWidth` (a `rememberSaveable` counter and a scrolled `LazyColumn` inside content; switch the forced size from 475×751 to 933×704 through a `mutableStateOf`; both keep their values) + - `inRightToLeftTheRailIsOnTheRight` (`DeviceConfigurationOverride.LayoutDirection(Rtl)`) + - `aCutoutAtEitherSideIsKeptClear` (`DeviceConfigurationOverride.WindowInsets` with a 40dp display cutout at the start, then at the end, at 933×704: the rail's items start after the start cutout; the content area ends before the end cutout; with `navigation = false` the content clears both) +- [ ] **Step 2:** Run the class; expect compile failure, then (after empty stubs) each test failing on its assertion. +- [ ] **Step 3:** Implement `AppFrame.kt` (`BoxWithConstraints` → `Row`) and the `DdsScaffold` overload. +- [ ] **Step 4:** Run the class; expect pass. Whole device suite, XML, pre-commit set. Commit: `feat(android): DdsAppFrame — the rail and a fixed list outside the screens, bottom tabs inside them`. + +### Task 6: Body column + +**Files:** +- Create: `android/dds-compose/src/main/kotlin/kr/devslab/dds/compose/layout/Body.kt` +- Test: `FrameOnDeviceTest.kt` + +**Interfaces:** +- Produces: + +```kotlin +public fun Modifier.ddsBody(max: Dp = DdsScreen.bodyMax.dp): Modifier // widthIn(max = max) + testTag(DdsTestTags.Body) +public fun Modifier.ddsBodyLines(max: Dp = DdsScreen.bodyMax.dp): Modifier // widthIn(max).fillMaxWidth().padding(horizontal = gutter); no tag +public object DdsBodyDefaults { @Composable public fun contentPadding(): PaddingValues } // the window gutter on all four sides +@Composable +public fun DdsBodyColumn( + modifier: Modifier = Modifier, + scroll: ScrollState = rememberScrollState(), + max: Dp = DdsScreen.bodyMax.dp, + spacing: Dp = DdsScreen.blockGap.dp, + contentPadding: PaddingValues = DdsBodyDefaults.contentPadding(), + content: @Composable ColumnScope.() -> Unit, +) +``` + + `DdsBodyColumn` centers the column in its area (`Box(fillMaxSize, TopCenter)`), scrolls, and applies `ddsScreenContent()` itself. + +- [ ] **Step 1:** Tests: + - `theBodyIsOneColumnInTheMiddleOfWhatTheFrameLeaves` — at 933×704 without a list: body left ≈ 80 + (853 − 680)/2 = 166.5dp, width 680dp (JeongsanNow's measured line); with the list: left ≈ 345dp, width ≈ 588dp; at 475×751: left 0, width 475dp. Tolerance 1dp. + - `theTextInTheBodyStartsOnTheGutter` — a tagged child's left is body left + 16dp at 475, + 24dp at 704 and 933. + - `aBarOnTheBodysLinesStartsWhereTheBodyTextStarts` — a box with `ddsBodyLines()` placed in the same area has the same left and right as that child. + - `theBodySlidesWhenTheScreenArrives` — inside `DdsScreenHost` with a `Push`, two frames in with the clock held, the body's left is to the trailing side of where it settles. +- [ ] **Step 2:** Run; expect failures. +- [ ] **Step 3:** Implement `Body.kt`. +- [ ] **Step 4:** Run; pass. Device suite, pre-commit set. Commit: `feat(android): the body column — one start line for every screen`. + +### Task 7: Top bar on the body's lines, and the tall bar + +**Files:** +- Modify: `.../component/Shell.kt` (`DdsTopBar`), `.../layout/AppFrame.kt` (compute `tallBar`), `android/dds-compose/src/main/res/values/dds_a11y.xml` and `values-ko/dds_a11y.xml` (`dds_a11y_back`: "Back" / "뒤로") +- Create: `.../layout/BarHeight.kt` +- Test: `FrameOnDeviceTest.kt`, `android/dds-compose/src/androidTest/.../ScreenReaderTest.kt` (back button's name) + +**Interfaces:** +- Produces: + +```kotlin +@Composable +public fun DdsTopBar( + title: String, + modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + onBack: (() -> Unit)? = null, + backEnabled: Boolean = true, + backDescription: String = stringResource(R.string.dds_a11y_back), + columnMax: Dp = DdsScreen.bodyMax.dp, + actions: @Composable RowScope.() -> Unit = {}, +) + +@Composable +public fun ddsBarNeedsTwoLines( + areaWidth: Dp, + titles: List, + actions: Int = 2, + reserve: (title: String) -> Dp = { 0.dp }, + columnMax: Dp = DdsScreen.bodyMax.dp, +): Boolean +``` + + Title room: `min(areaWidth, columnMax) − 2·gutter − 40 − 48·actions − reserve(title)`, measured with `rememberTextMeasurer()` in the `title2` style. In `DdsAppFrame`, `tallBar = barTitles.isNotEmpty() && ddsBarNeedsTwoLines(areaWidth = maxWidth − rail − list, barTitles, barActions, barReserve)`. `DdsTopBar`: content `widthIn(max = columnMax)` centered; title tagged `DdsTestTags.TopBarTitle`, `maxLines = if (tall) 2 else 1`; min height `DdsBar.topTall` or `DdsBar.top`; the divider and background span the whole area. + +- [ ] **Step 1:** Tests (933×704 unless said): + - `theBackArrowAndTheLastIconStandOnTheBodysLines` — the back button's bounds center ± 12dp gives a glyph left equal to body left + 24dp; the last action button's glyph right equals body right − 24dp. + - `theTitleStartsInTheSamePlaceAfterAMarkAndAfterAnArrow` — title left is body left + gutter + 40dp in both, at 475×751 and 933×704. + - `aTitleWithNothingBeforeItStartsOnTheBodysLine`. + - `aBarWithNoActionsAndOneWithAButtonPassedAsLeadingDoNotBreak` — title bounds inside the bar, no overlap with the 48dp leading. + - `oneLongNameMakesEveryBarInTheWindowTall` — with `barTitles` holding a name that needs two lines at `FontScale(1.5f)`, a screen whose own title is short has a bar 104dp tall; without `barTitles` it is 64dp and the long title is on one line. + - `barReserveTakesRoomFromTheTitle` — a title that fits alone needs two lines once `barReserve` returns 120.dp. + - In `ScreenReaderTest`: `theBackButtonHasAName`. +- [ ] **Step 2:** Run; expect failures (and note which existing `DdsTopBar` tests still pass). +- [ ] **Step 3:** Implement. Start padding `gutter − 12` when `onBack != null` (button 48, then 4), `gutter` otherwise (leading slot `widthIn(min = 28.dp)`, then 12). Actions sit in a row that reports its width 12dp short on the end side so the last 48dp button hangs into the gutter. Update the KDoc: back goes through `onBack`. +- [ ] **Step 4:** Run; pass. Device suite (the 22 `ScreenReaderTest` cases must still pass), XML, pre-commit set. Commit: `feat(android): the top bar keeps to the body's lines; one tall bar for the whole window`. + +### Task 8: Centered block and the pinned list rows + +**Files:** +- Create: `.../layout/Centered.kt`, `.../component/Pane.kt` +- Test: `FrameOnDeviceTest.kt`, `ScreenReaderTest.kt` + +**Interfaces:** +- Produces: + +```kotlin +@Composable public fun DdsCenteredColumn(modifier: Modifier = Modifier, max: Dp = DdsScreen.centeredMax.dp, content: @Composable ColumnScope.() -> Unit) +@Composable public fun DdsCenteredTitle(title: String, modifier: Modifier = Modifier, description: String? = null) +@Composable public fun DdsPaneList(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) +@Composable public fun DdsPaneItem(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true) +``` + + `DdsCenteredColumn`: area tagged `CenteredArea`, block tagged `Centered`, block centered both ways, side padding the gutter, vertical padding `DdsScreen.blockGap`; when taller than the area it starts at the top and scrolls (`fillMaxHeight().verticalScroll()` with `Arrangement.Center`). `DdsCenteredTitle`: `title1`, centered, `LineBreak.Heading`, explicit line breaks kept, one heading to a screen reader; description `body2` in `textSecondary`. `DdsPaneItem`: min height `DdsControl.md` (`DdsTouch.large` in large text), `selectable(role = Role.Tab)`, selected = `bgBrandSubtle` fill + `textBrand` + SemiBold through `colorChange`, focus ring; `DdsPaneList`: scrolls, `selectableGroup()`, outer side padding `gutter − 12` so the label (inner padding 12) stands on the pane's gutter line. + +- [ ] **Step 1:** Tests: + - `theOneBlockIsInTheMiddleAndNoWiderThanItsLimit` — at 933×704: block width 400dp, equal space left/right and (within 1dp) above/below; at 475×751: width 475 − 32. + - `aBlockTallerThanTheWindowStartsAtTheTopAndScrolls` — at `FontScale(2f)` with ten lines: the first child's top is at the area's top padding and the last can be scrolled to. + - `theItemChosenInTheListLooksLikeTheRailsChoice` — the selected item's fill pixel equals the rail's selected pill color; the label is heavier. + - `aListItemsLabelStandsOnTheGutterLine` — label left = pane left + 24dp at 933×704. + - `ScreenReaderTest`: `theCenteredTitleIsOneHeading`, `aListItemSaysItIsChosenAndWhereItIs`. +- [ ] **Step 2:** Run; fail. +- [ ] **Step 3:** Implement the two files. +- [ ] **Step 4:** Run; pass. Device suite, pre-commit set. Commit: `feat(android): the centered block for a screen with one thing to do; rows for the fixed list`. + +### Task 9: `dds-compose-testing` + +**Files:** +- Create: `android/dds-compose-testing/build.gradle.kts`, `android/dds-compose-testing/src/main/kotlin/kr/devslab/dds/compose/testing/FrameCheck.kt`, `android/dds-compose-testing/src/main/AndroidManifest.xml` (if the plugin needs one) +- Modify: `android/settings.gradle.kts` (`include(":dds-compose-testing")`), `android/dds-compose/build.gradle.kts` (`androidTestImplementation(project(":dds-compose-testing"))`), `.github/workflows/android.yml` and `android-release.yml` (add `:dds-compose-testing:lintRelease` / `:assembleRelease` beside the other two) +- Test: `android/dds-compose/src/androidTest/kotlin/kr/devslab/dds/compose/FrameCheckTest.kt` + +**Interfaces:** +- Consumes: `DdsTestTags`, `ddsWindowWidth`. +- Produces (artifact `kr.devslab:dds-compose-testing`, namespace `kr.devslab.dds.compose.testing`, `api(libs.compose.ui.test.junit4)`, `api(project(":dds-compose"))`, `mavenPublishing { coordinates(artifactId = "dds-compose-testing"); pom { name.set("DDS Compose Testing") } }`): + +```kotlin +public enum class DdsScreenKind { TopLevel, Inner, Listed, WholeWindow } + +public class DdsScreenReading( + public val name: String, public val kind: DdsScreenKind, public val window: DdsWindowWidth, + public val bodyLeftDp: Float, public val bodyWidthDp: Float, public val titleLeftDp: Float, + public val rail: Boolean, public val listPane: Boolean, public val bottomTabs: Boolean, +) + +public class DdsFrameCheck(private val rule: ComposeTestRule) { + public fun look(name: String, kind: DdsScreenKind): DdsScreenReading + public fun assertSameLines() + public val readings: List +} +``` + + `look` throws `AssertionError` naming the screen when: visible `Body` nodes ≠ 1; the screen's title (the visible `TopBarTitle` whose horizontal center lies inside the body's x-range) is not within the body's x-range ± 1px; the body's center is more than 1dp from the center of the area between (list pane right ?: rail right ?: window left) and window right; the menus differ from the table in spec §9.2; a list pane does not start at the rail's right or the body starts before the pane ends. `assertSameLines` throws when two readings of the same kind and the same `listPane` differ in `bodyLeftDp` or `bodyWidthDp` by more than 1dp. + +- [ ] **Step 1:** `FrameCheckTest`: `aFrameThatKeepsTheRulesPasses` (the Task 5 helper frame with `DdsBodyColumn` and `DdsTopBar`, three sizes, four kinds); then one test per rule that builds a broken screen and expects `look` to throw with the screen's name in the message: two bodies; a top bar with `columnMax` 400 over a 680 body is allowed but a full-width title outside the body is not (title not capped); a body aligned to the start instead of centered; a rail on a `WholeWindow` screen; bottom tabs on an `Inner` screen; and `twoScreensOfAKindOnDifferentLinesFail` for `assertSameLines`. Also `withAFixedListTheScreensTitleIsRead` (two top bars on screen). +- [ ] **Step 2:** Run; compile failure, then failures. +- [ ] **Step 3:** Create the module and implement `FrameCheck.kt`. +- [ ] **Step 4:** Run; pass. `./gradlew :dds-compose-testing:lintRelease :dds-compose-testing:assembleRelease publishToMavenLocal` and confirm `~/.m2/repository/kr/devslab/dds-compose-testing//` holds a pom and an aar. Device suite, pre-commit set. Commit: `feat(android): dds-compose-testing — an app checks its own screens against the frame`. + +### Task 10: The remaining device checks + +**Files:** +- Test: `FrameOnDeviceTest.kt`, `MotionOnDeviceTest.kt` + +**Interfaces:** +- Consumes: everything above, `DdsScreenHost`, `ddsScreenChange`. + +- [ ] **Step 1:** Write, see fail by breaking the frame locally (move the rail inside the host; draw the bottom tabs in the frame), then restore: + - `theRailDoesNotChangeWhileTheScreenBesideItChanges` — clock held, `DdsScreenHost` inside `DdsAppFrame` at 933×704: across 12 frames of a Push the rail's captured pixels are identical and its bounds do not move. + - `openingAScreenOverATabCoversTheBottomTabsWithNoFrameEmpty` — 475×751: red top-level screen with bottom tabs, blue inner screen; every frame is red/blue/tab-bar colors only, and at the end no `BottomTabs` node is displayed. + - `everyScreenOfTheSampleAppKeepsTheFrame` — a small sample app in the test (two tabs, an inner screen, a listed area, a whole-window screen, a centered gate) walked with `DdsFrameCheck` at the three sizes, then `assertSameLines()`. + - `atTwiceTheFontNothingInTheFrameIsCut` — 475×751 and 933×704 at `FontScale(2f)`: no text node under the frame reports `hasVisualOverflow`. +- [ ] **Step 2:** Run; pass after restoring. Whole device suite; read the XML (`skipped` must be 0 with animations on). Pre-commit set. Commit: `test(android): the frame through a change of screen, and a whole sample app at three widths`. + +### Task 11: Catalog and the look of it + +**Files:** +- Modify: `android/catalog/src/main/kotlin/kr/devslab/dds/catalog/MainActivity.kt`, `Pages.kt` +- Create: `android/catalog/src/main/kotlin/kr/devslab/dds/catalog/LayoutPages.kt` + +**Interfaces:** +- Consumes: the whole public API of this plan. + +- [ ] **Step 1:** Move the catalog onto `DdsAppFrame` + `DdsScaffold(bottomTabs = true)` + `DdsBodyColumn` + `DdsTopBar(onBack = …)`. Add a fifth destination "Layout": a list of four demos (body column, top bar, centered block, whole window). At Expanded the list is pinned (`list = { DdsPaneList { … DdsPaneItem … } }`) and choosing swaps the screen; narrower, the list is a screen and a demo opens over it. The "whole window" demo sets `navigation = false`; the centered demo shows `DdsCenteredColumn` + `DdsCenteredTitle` with a field and a button. +- [ ] **Step 2:** Install on `emulator-5590`. For each of 475×751, 704×933, 933×704 (`adb -s emulator-5590 shell wm size` / `wm density`, reset afterwards with `wm size reset`, `wm density reset`) × light and dark × normal and large text: capture the tab screen, an inner screen, the Layout list and a demo beside it, the centered demo. Draw the body's start and end lines on each capture (a script over the PNGs using the measured dp) and look: the arrow glyph, the title, the last icon and the body text stand on those lines; rows are of even height; nothing is cut; the rail and list are where the table says. +- [ ] **Step 3:** Fix what the captures show, with a test first for each defect. Re-capture. +- [ ] **Step 4:** Reset the emulator's size, density and animation scale; shut it down. Pre-commit set. Commit: `feat(android): the catalog on the new frame, with a layout page`. + +### Task 12: Documents, version, hand-over + +**Files:** +- Modify: `docs/mobile-native.md` + `.ko.md` (a new "Screen frame / 화면 틀" section with the rules, numbers, the two samples — a Navigation 3 app and a `DdsScreenHost` app — and what was checked; the component table; §10 item 2: the gutter is decided), `android/CHANGELOG.md` + `.ko.md` (0.7.0), `android/README.md` + `.ko.md` (third artifact, install lines, device tests), `README.md` + `README.ko.md` (version line), `docs/decisions.md` (D-052: built, what was verified, what was not), `docs/backlog.md`, `android/gradle.properties` (`VERSION_NAME=0.7.0`), the spec's status line and a "달라진 것" list if the build departed from it +- Create: shared note `dds-android-0.7.0-consumer.md` + +- [ ] **Step 1:** Write the guide section and the changelog from what was built and measured, saying plainly what was not checked (a physical phone; the back swipe; light on a phone). List the visible changes of spec §10 under "If you move up". +- [ ] **Step 2:** Write the consumer note: the local Maven build (`publishToMavenLocal`, commit hash, time), the JeongsanNow mapping table of spec §10, the three windows to check on the phone and what to look for (start lines, title position, rail through a change, bottom tabs covered, large text), and that nothing is on Maven Central yet. +- [ ] **Step 3:** Full verification: unit tests, lint, assemble, whole device suite with the XML read, `pnpm run verify`, `pnpm run verify:source:stage1-2`, `publishToMavenLocal`. Commit: `docs(android): the screen frame in the guide; 0.7.0`. +- [ ] **Step 4:** Push; open the PR (title and body in English and Korean: what changed, what was checked, what was not); read CI to the end. Do not merge before the owner says so, and do not tag: the release waits for JeongsanNow's phone check (spec §3). diff --git a/docs/superpowers/specs/2026-11-12-dds-android-screen-frame-design.ko.md b/docs/superpowers/specs/2026-11-12-dds-android-screen-frame-design.ko.md new file mode 100644 index 00000000..6c515d59 --- /dev/null +++ b/docs/superpowers/specs/2026-11-12-dds-android-screen-frame-design.ko.md @@ -0,0 +1,480 @@ +# DDS 안드로이드 화면 틀 — 설계 + +상태: **만들었다, 아직 배포 전** (2026-11-12). 소유자가 대화에서 세 구간을 차례로 확인한 설계대로 +브랜치 `feat/android-screen-frame`에 만들었다. 정산나우가 로컬 빌드로 옮겨 실물 폰의 창 세 가지에서 확인한 +뒤에 배포한다(§3). 결정 기록은 `docs/decisions.md` D-052. 대상 버전은 안드로이드 0.7.0. + +## 달라진 것 (만들면서 설계와 다르게 된 것) + +- **검사가 돌려주는 값에 둘이 더 있다.** `DdsScreenReading`에 `windowWidthDp`(창 폭, dp)와 `centered`(가운데 + 묶음인지)가 있다. `titleLeftDp`는 `Float?`다. 상단 줄 제목이 없는 화면과 가운데 묶음은 null이다. +- **검사는 가운데 묶음 하나를 본문 하나로 센다.** 그래야 로그인 화면도 볼 수 있다. 제목 규칙은 가운데 묶음에는 + 쓰지 않는다. `assertSameLines`는 종류와 목록 유무에 더해 창 폭과 본문·가운데 묶음으로도 묶는다. 같은 앱을 + 두 창에서 보면 그래야 한다. +- **475×751에서 가운데 묶음의 폭은 400이다.** 구현 계획은 475 − 32를 기대했지만 443은 400을 넘는다. 여백이 + 폭을 정하는 경우는 360×751에서 시험했다. +- **가운데 묶음(§8)이 하는 일이 늘었다.** 시스템 줄과 키보드가 남긴 자리의 가운데에 놓인다(입력칸이 키보드 + 위에 남고, 큰 글자에서 상태 줄 아래에서 시작한다). 조각 사이를 24 띄운다. `ddsScreenContent()`를 스스로 붙여 + 화면과 함께 밀려 들어온다. 자리 전체에 페이지 바탕(`bgDefault`)을 칠한다. 밀려 들어오는 동안에도 바탕은 + 제자리라, `NavDisplay`처럼 화면 뒤에 아무것도 칠하지 않는 전환기에서도 아래 화면을 덮는다(D-051). +- **고정 목록의 고르지 않은 줄은 회색이다**(`textSecondary`). 레일의 고르지 않은 항목과 같다. §5.5는 고른 + 줄만 정했다. +- **레일 라벨이 200%에서 세 줄이 필요하면** 두 번째 줄 뒤에서 말줄임표로 자른다. 레일은 80 그대로다. +- **옆쪽 카메라 구멍이 있으면 레일이 차지하는 폭은 구멍 + 80이다.** `Rail` 표시가 달린 부분은 그 안의 80이다. +- **검사 꾸러미가 Espresso 3.7.0을 함께 가져온다.** Compose BOM이 가져오는 3.5.0은 Android 17에서 돌지 않는다. +- **기기 테스트 전체를 실물 폰에서도 돌렸다.** §9.4는 "실물 폰은 DDS 쪽에서 건드리지 않는다"였지만, 소유자가 + 폰에서 시험하라고 했다. 라이브러리 테스트만 돌렸고 카탈로그는 폰에 깔지 않았다. 카탈로그 캡처는 에뮬레이터에서 + 줄인 조합(세 창 밝은 화면, 933×704 어두운 화면, 475×751 가장 큰 글자와 키보드)으로 했다. + +## 1. 왜 만드는가 + +정산나우에서 소유자가 펼친 폰으로 직접 찾아낸 문제다(요청서: 공유 노트 +`dds-android-frame-request-2026-11-11.md`). + +- 화면마다 본문 시작선이 달랐다. 펼친 가로(933×704dp)에서 잰 값이 187, 147, 127, 그리고 왼쪽 끝부터 + 오른쪽 끝까지인 화면도 있었다. +- 왼쪽 레일이 탭 화면에만 있고, 다른 화면을 열면 사라졌다. +- 상단 줄(뒤로·제목·새로고침)은 화면 왼쪽 끝에서, 본문은 가운데에서 시작해 서로 다른 선에 있었다. + +원인은 하나다. **DDS에 숫자는 있는데 그 숫자를 지키게 하는 부품이 없다.** 그래서 화면마다 폭을 손으로 +적었고 680, 720, 760으로 갈라졌다. 소유자의 말: "이런 기준들도 DDS에서 정의 못하는 거야? 시작선이라던가." + +목표는 세 가지다. + +1. 창 폭이 메뉴를 정한다. 화면은 폭을 재지 않는다. +2. 본문은 한 기둥에 서고, 상단 줄은 그 기둥의 선에 맞는다. 부품을 쓰면 저절로 그렇게 된다. +3. 앱이 자기 화면들이 이 규칙을 지키는지 검사할 수 있다. + +## 2. 지금 있는 것 + +| | DDS 0.5.0 | 정산나우 | Qooroo | +| --- | --- | --- | --- | +| 창 폭 기준 | 600 하나 | 600, 840 | 600, 840 | +| 옆 여백 | 16(좁음) / 24(넓음) 값만 있음. `DdsTopBar`는 늘 16 | 20 | 16 / 24 | +| 메뉴 | `DdsScaffold`: 600 이상이면 레일을 상단 줄 **아래**에 그림 | 레일과 고정 목록을 화면 전환기 **바깥**에, 하단 탭은 탭 화면 안에 | 탭 없음. 840 이상에서 목록과 상세를 반반 | +| 본문 폭 | 없음 | 최대 680, 가운데 | 최대 600, 가운데 | +| 상단 줄 | 창 전체 폭 | 본문 기둥 폭, 그림을 본문 선에 맞춤, 두 줄 제목 | 자체 | +| 한 가지 일 화면 | 없음 | 가운데 묶음, 최대 400 | 로그인 열 자체 폭 | + +정산나우의 구조는 실물 폰(SM-F971N)의 창 세 가지(접힌 475×751, 펼친 세로 704×933, 펼친 가로 933×704)에서 +전체 연결 테스트로 확인됐다. 이 설계는 그 구조를 DDS 부품으로 옮긴다. + +## 3. 소유자가 정한 것 (2026-11-12) + +| 항목 | 결정 | +| --- | --- | +| 옆 여백 | 16(창 폭 600 미만) / 24(600 이상). 정산나우의 20은 이 값으로 옮긴다 | +| 만드는 방식 | DDS 틀 부품이 메뉴를 그린다. Material의 적응형 틀을 감싸는 안과 숫자만 주는 안은 버렸다(§12) | +| 중간 폭(600~839) | 레일만. 목록은 한 화면으로 열고 뒤로 돌아온다 | +| 숫자 | 본문 최대 680, 고정 목록 시작 840, 고정 목록 폭 264, 가운데 묶음 400. 정산나우 값 | +| 기존 `DdsScaffold` | 그대로 두고 새 틀을 더한다 | +| 검사 도우미 | 0.7.0에 새 꾸러미로 넣는다 | +| 배포 조건 | 정산나우가 로컬 빌드로 옮겨 실물 폰의 창 세 가지에서 확인한 뒤 Maven에 올린다 | + +## 4. 숫자 + +`tokens/native.json`에 더하고 `DdsScreen`·`DdsBar`로 생성한다. 생성물과 가이드의 표는 기존 어긋남 검사가 지킨다. + +| 토큰 | 코틀린 | 값 | 뜻 | +| --- | --- | --- | --- | +| `screen.breakpoint-expanded` | `DdsScreen.breakpointExpanded` | 840 | 고정 목록을 옆에 두기 시작하는 창 폭 | +| `screen.body-max` | `DdsScreen.bodyMax` | 680 | 본문 기둥의 최대 폭. 옆 여백을 포함한다 | +| `screen.list-pane` | `DdsScreen.listPane` | 264 | 고정 목록의 폭 | +| `screen.centered-max` | `DdsScreen.centeredMax` | 400 | 가운데 묶음의 최대 폭 | +| `bar.rail` | `DdsBar.rail` | 80 | 레일의 폭 | +| `bar.top-tall` | `DdsBar.topTall` | 104 | 두 줄 제목일 때 상단 줄 높이 | + +기존 값은 그대로다: `gutter` 16, `gutterWide` 24, `breakpoint` 600, `blockGap` 24, `DdsBar.top` 64, `DdsBar.bottom` 80. + +**창 폭 분류** + +```kotlin +enum class DdsWindowWidth { Compact, Medium, Expanded } +fun ddsWindowWidth(width: Dp): DdsWindowWidth // 600 미만 / 840 미만 / 그 이상 +val DdsWindowWidth.gutter: Dp // Compact 16, 나머지 24 +``` + +**옆 여백은 창 폭으로 정한다.** 화면에 남은 영역의 폭이 아니다. 고정 목록 옆의 화면은 588dp밖에 안 되지만 +창이 넓으므로 24를 쓴다. 한 창 안에서 상단 줄, 본문, 고정 목록이 모두 같은 여백을 쓰게 하기 위해서다. + +## 5. 화면 틀 + +### 5.1 누가 무엇을 그리는가 + +| | 누가 | 어디에 | 이유 | +| --- | --- | --- | --- | +| 레일 | 틀 | 화면 전환기 바깥 | 모든 화면에 있다. 화면이 바뀌어도 그대로 있어야 한다 | +| 고정 목록 | 틀(자리) + 앱(내용) | 화면 전환기 바깥 | 위와 같다. 스크롤 위치와 접근성 초점도 유지된다 | +| 하단 탭 | 최상위 화면 | 화면 전환기 안 | 상세 화면을 열 때 새 화면이 탭 줄까지 덮어야 한다. 틀이 그리면 탭 줄이 뚝 사라지고 내용이 튄다 | +| 상단 줄 | 각 화면 | 화면 전환기 안 | 화면마다 이름과 버튼이 다르다 | + +레일은 틀이, 하단 탭은 화면이 그리는 비대칭이 핵심이다. 0.5.0의 규칙("화면이 바뀌는 동안 두 화면이 겹쳐 +있다", D-051)과 맞물린다. 전환기 바깥에 있는 것은 전환의 영향을 받지 않는다. + +### 5.2 `DdsAppFrame` + +```kotlin +@Composable +fun DdsAppFrame( + navItems: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + navigation: Boolean = true, + barTitles: List = emptyList(), + barActions: Int = 2, + barReserve: (title: String) -> Dp = { 0.dp }, + list: (@Composable () -> Unit)? = null, + content: @Composable () -> Unit, +) +``` + +- `content`는 화면 전환기다(`NavDisplay` 또는 `DdsScreenHost`). 틀은 앱에 하나만 둔다. +- `navigation = false`이면 메뉴 없이 창 전체를 쓴다. 로그인 전, 그리고 작성·스캔처럼 한 가지 일만 하는 + 화면에서 앱이 끈다. +- `list`는 지금 화면이 속한 영역에 목록이 있을 때만 넘긴다. 없으면 `null`. +- `barTitles`, `barActions`, `barReserve`는 두 줄 제목 판단에 쓴다(§7.3). + +**창 폭별 동작** + +| 창 폭 | 틀이 그리는 것 | 화면이 그리는 것 | +| --- | --- | --- | +| Compact | 없음 | 최상위 화면이 하단 탭 | +| Medium | 레일 | — | +| Expanded | 레일. `list`가 있으면 레일 옆에 목록(264 + 1dp 선) | — | +| `navigation = false` | 없음 | — | + +`navItems`가 비어 있으면 레일과 하단 탭은 없다. 고정 목록은 탭이 없어도 둘 수 있다 +(`navigation && Expanded && list != null`). + +**틀이 화면에 알려 주는 값** + +```kotlin +class DdsFrame( + val width: DdsWindowWidth, + val rail: Boolean = false, + val listPinned: Boolean = false, + val tallBar: Boolean = false, +) +val LocalDdsFrame: ProvidableCompositionLocal // 틀 밖에서는 null +@Composable fun ddsFrame(): DdsFrame // 틀 밖에서는 창 크기로 만든 값 +``` + +화면은 스스로 폭을 재지 않고 `ddsFrame()`만 읽는다. 그래서 틀과 화면이 같은 메뉴를 둘 다 그리거나 둘 다 +빼먹을 수 없다. 틀 밖(틀을 쓰지 않는 앱, 미리보기)에서는 창의 폭으로 `width`만 채우고 나머지는 `false`다. +탭 목록과 선택은 틀이 내부 값으로 내려보내고, 화면은 그것을 다시 받지 않는다. + +**그 밖에 틀이 책임지는 것** + +- 창 폭 분류가 바뀌어도(접기, 펴기, 돌리기) `content`의 상태는 유지된다. 레일과 목록이 나타나거나 + 사라져도 전환기는 같은 자리에 있다. +- 가로로 눕혔을 때 카메라 구멍과 시스템 줄을 피한다. 레일이 시작 쪽을 맡고, 내용 영역이 끝 쪽을 맡는다. + 레일도 목록도 없으면 내용 영역이 양쪽을 맡는다. 위아래는 화면의 부품(`DdsTopBar`, `DdsScaffold`)이 맡는다. +- 오른쪽에서 왼쪽으로 쓰는 언어에서는 레일과 목록이 오른쪽에 온다. +- 바탕은 `bgDefault`다. + +### 5.3 `DdsScaffold` — 화면 하나의 뼈대 + +새 형태를 더한다. 기존 형태(탭 목록을 직접 받는 것)는 그대로 동작한다. + +```kotlin +@Composable +fun DdsScaffold( + topBar: @Composable () -> Unit, + modifier: Modifier = Modifier, + bottomTabs: Boolean = false, + content: @Composable (PaddingValues) -> Unit, +) +``` + +- `bottomTabs = true`는 최상위 화면이 쓴다. 틀이 Compact이고 메뉴가 켜져 있을 때만 하단 탭이 그려진다. + 탭 목록과 선택은 틀에서 온다. +- 틀 안에서는 이 형태를 쓴다. 기존 형태를 틀 안에 넣으면 레일이 둘이 된다. +- 고정 목록의 내용도 이 뼈대와 `DdsTopBar`로 짠다. + +### 5.4 레일과 하단 탭 + +- 레일의 폭은 80으로 고정한다(`DdsBar.rail`). +- 라벨은 두 줄까지 쓴다. 지금은 한 줄에서 자른다. 큰 글자에서 "거래처 매출" 같은 라벨이 잘리지 않게 + 하기 위해서이고, 항목과 하단 탭의 높이는 두 줄이 들어가도록 글자 크기에 따라 늘어난다(최소값은 그대로). +- 레일은 항목이 다 들어가지 않으면 위아래로 넘긴다. +- 선택 표시는 그대로다(옅은 브랜드 알약, 브랜드 색, 굵은 글자). + +### 5.5 고정 목록 + +틀은 자리(폭 264, 끝 쪽 1dp 선)만 준다. 내용은 앱의 것이다. + +- **같은 목록 하나에서 나온다.** 좁거나 중간 폭에서는 앱이 같은 목록을 한 화면으로 연다. 틀이 알려 주는 + `listPinned`를 보고 화면은 자기 쪽의 이동 버튼을 숨긴다. +- **이동 규칙(앱이 지킨다).** 고정 목록 옆에서 고르는 것은 지금 화면을 바꿔 끼운다. 뒤로 가기는 고른 + 순서를 되밟지 않고 영역을 나간다. 목록이 한 화면일 때는 "목록 → 화면 → 뒤로"다. +- **목록 줄 부품을 더한다.** 지금은 정산나우가 손으로 그린다. + +```kotlin +@Composable fun DdsPaneList(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) +@Composable fun DdsPaneItem(label: String, selected: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, enabled: Boolean = true) +``` + +`DdsPaneItem`의 선택 표시는 레일과 같다(옅은 브랜드 바탕, 브랜드 색, 굵은 글자. 색은 100ms에 걸쳐 +바뀐다). 높이는 최소 48이고 큰 글자 모드에서는 56이다. `DdsPaneList`는 넘치면 스크롤되고, 줄의 글자가 +목록의 옆 여백 선에 서도록 바깥 여백을 맞춘다. 화면 낭독기에는 "선택됨, 7개 중 2번째"로 읽힌다. + +## 6. 본문 기둥 + +```kotlin +fun Modifier.ddsBody(max: Dp = DdsScreen.bodyMax.dp): Modifier +fun Modifier.ddsBodyLines(max: Dp = DdsScreen.bodyMax.dp): Modifier +@Composable +fun DdsBodyColumn( + modifier: Modifier = Modifier, + scroll: ScrollState = rememberScrollState(), + max: Dp = DdsScreen.bodyMax.dp, + spacing: Dp = DdsScreen.blockGap.dp, + contentPadding: PaddingValues = DdsBodyDefaults.contentPadding(), + content: @Composable ColumnScope.() -> Unit, +) +``` + +- **`DdsBodyColumn`** 은 상단 줄 아래 본문 전체다. 스크롤되는 한 단이고, 최대 폭으로 틀이 남긴 영역의 + 가운데에 놓인다. 기본 여백은 네 방향 모두 창의 옆 여백 값(16 또는 24)이고 블록 사이는 24다. 화면이 + 바뀔 때 밀리는 부분이 본문이므로 `ddsScreenContent()`를 스스로 붙인다(따로 붙이지 않는다). +- **`ddsBody()`** 는 본문을 직접 짜는 화면이 쓴다. 폭을 제한하고 검사용 표시를 단다. 가운데에 놓는 것과 + 여백은 화면이 한다. +- **`ddsBodyLines()`** 는 본문은 아니지만 본문과 같은 선에 서야 하는 것에 쓴다(진행 표시줄, 단계 표시, + 아래쪽 버튼 줄). 폭을 제한하고 옆 여백을 준다. 검사용 표시는 달지 않는다. +- **규칙.** 화면마다 본문 기둥은 하나다. 문서 쪽이나 넓은 표처럼 680이 맞지 않는 화면은 `max`를 넘기고, + 상단 줄의 `columnMax`에도 같은 값을 넘긴다. + +## 7. 상단 줄 (`DdsTopBar`) + +```kotlin +@Composable +fun DdsTopBar( + title: String, + modifier: Modifier = Modifier, + leading: (@Composable () -> Unit)? = null, + onBack: (() -> Unit)? = null, + backEnabled: Boolean = true, + backDescription: String = /* "뒤로" · "Back" */, + columnMax: Dp = DdsScreen.bodyMax.dp, + actions: @Composable RowScope.() -> Unit = {}, +) +``` + +### 7.1 본문 기둥 안에 둔다 + +상단 줄의 내용은 `columnMax` 폭으로 영역의 가운데에 놓인다. 본문과 같은 폭, 같은 자리다. 아래 1dp 선과 +바탕은 영역 전체 폭이다. 옆 여백은 창의 값(16 또는 24)을 쓴다. 지금은 창 폭과 상관없이 16이다. + +### 7.2 선에 맞추는 규칙 + +`g`는 옆 여백이다. + +| 무엇 | 자리 | +| --- | --- | +| 뒤로 화살표의 그림(24) | 왼쪽 끝이 본문 시작선(`g`)에 선다. 눌리는 영역(48)은 여백 쪽으로 12 나간다 | +| 로고(`leading`) | 본문 시작선에서 시작한다. 자리는 최소 28 폭이다 | +| 제목 | 화살표 뒤든 로고 뒤든 `g + 40`에서 시작한다. 둘 다 없으면 `g`에서 시작한다 | +| 마지막 아이콘의 그림 | 오른쪽 끝이 본문 끝선에 선다. 눌리는 영역은 여백 쪽으로 12 나간다 | + +- 탭 화면(로고 28 + 간격 12)에서 상세(화살표)로 들어가도 제목이 옆으로 움직이지 않는다. +- 뒤로 가기는 `onBack`으로 넘긴다. 부품이 화살표를 그리고 정렬을 맡는다. `onBack`과 `leading`을 함께 + 주면 화살표만 그린다. +- 끝의 동작은 아이콘 버튼으로 한다. 글자 버튼을 끝에 두면 글자가 여백으로 12 나가므로 앱이 끝 여백 12를 준다. +- 28보다 넓은 것을 `leading`에 넣어도 깨지지 않는다(자리가 넓어지고 제목이 그만큼 밀린다). 기존에 + `leading`으로 뒤로 버튼을 넣던 코드는 그대로 동작하지만 선에 맞지 않으므로 `onBack`으로 옮긴다. + +### 7.3 두 줄 제목 + +상단 줄의 높이는 본문의 세로 시작선이다. 화면마다 다르면 본문이 위아래로 튄다. 그래서 창 단위로 정한다. + +- 앱이 상단 줄에 쓰는 이름들을 `barTitles`로 틀에 넘긴다. 지금 글자 크기에서 그중 하나라도 한 줄에 들어가지 + 않으면 `ddsFrame().tallBar`가 참이 되고, 그 창의 모든 `DdsTopBar`가 높이 104에 제목 두 줄이 된다. +- 제목이 쓸 수 있는 폭은 `min(영역 폭, columnMax) − 2g − 40 − 48 × barActions − barReserve(제목)`이다. + 영역 폭은 창에서 레일과 고정 목록을 뺀 값이고, 40은 화살표나 로고의 자리, 48은 끝 아이콘 하나의 폭이다. + `barActions`는 어느 상단 줄에든 끝에 놓이는 아이콘의 최대 개수다(기본 2). `barReserve`는 제목 옆에 늘 + 놓이는 것(단계 화면의 "2 / 4")의 폭을 앱이 알려 주는 자리다. +- `barTitles`를 넘기지 않으면 지금처럼 높이 64에 한 줄이고 넘치면 자른다. 틀 밖에서도 같다. +- 같은 계산을 앱이 직접 쓸 수 있게 `ddsBarNeedsTwoLines(areaWidth, titles, actions, reserve, columnMax)`를 + 내놓는다. + +### 7.4 그대로인 것 + +아래 1dp 선은 항상 있다(정산나우는 본문이 스크롤됐을 때만 보여 준다). 바탕은 페이지 바탕이고 그림자와 +색조는 없다. 화면이 바뀔 때 움직이지 않는다. + +## 8. 한 가지 일만 하는 화면 + +```kotlin +@Composable +fun DdsCenteredColumn( + modifier: Modifier = Modifier, + max: Dp = DdsScreen.centeredMax.dp, + content: @Composable ColumnScope.() -> Unit, +) +@Composable +fun DdsCenteredTitle(title: String, modifier: Modifier = Modifier, description: String? = null) +``` + +- **`DdsCenteredColumn`.** 로그인 전, 잠금처럼 할 일이 하나인 화면용이다. 내용이 주어진 영역의 한가운데에 + 최대 400 폭의 한 묶음으로 놓이고 안의 것들은 가운데 정렬이다. 옆 여백은 창의 값, 위아래 여백은 24다. + 글자가 커서 묶음이 창보다 길어지면 위에서부터 시작하고 스크롤된다. 넓은 창의 구석에 붙은 묶음은 덜 + 불러와진 화면처럼 읽힌다는 것이 정산나우에서 나온 지적이다. +- **`DdsCenteredTitle`.** 가운데 정렬 제목과 그 아래 한 문장이다. 마지막 줄에 한 단어만 남지 않도록 줄 + 길이를 고르게 나눈다. 제목 안의 줄바꿈은 지킨다. 화면 낭독기에는 제목 하나로 읽힌다. +- 제목이 한 줄씩 차례로 올라오게 하는 연결 고리는 넣지 않는다. 모션 요청과 함께 다룬다(§11). + +## 9. 검사 + +### 9.1 표시 + +검사가 찾는 표시를 `dds-compose`가 공개 상수로 내놓는다(`DdsTestTags`): 본문 기둥, 상단 줄 제목, 레일, +고정 목록 자리, 하단 탭, 가운데 묶음과 그 영역. 부품이 스스로 단다. + +### 9.2 앱이 쓰는 도우미 — `kr.devslab:dds-compose-testing` + +새 꾸러미다. 앱의 기기 테스트(`androidTestImplementation`)가 쓰고, 다른 둘과 같은 버전으로 함께 낸다. + +```kotlin +enum class DdsScreenKind { TopLevel, Inner, Listed, WholeWindow } + +class DdsFrameCheck(rule: ComposeTestRule) { + fun look(name: String, kind: DdsScreenKind): DdsScreenReading + fun assertSameLines() +} +``` + +앱의 테스트가 실제 화면을 하나씩 열면서 `look`을 부른다. 그 화면에서 확인하는 것: + +1. 보이는 본문 기둥이 정확히 하나다. +2. 화면의 제목이 본문 기둥의 좌우 안에 있다. +3. 본문이 틀이 남긴 영역(고정 목록의 끝 또는 레일의 끝부터 창 끝까지)의 가운데에 있다. +4. 창 폭과 화면 종류에 맞는 메뉴만 있다. + +| 종류 | 레일 | 고정 목록 | 하단 탭 | +| --- | --- | --- | --- | +| `TopLevel`(탭 화면) | Medium 이상 | 없음 | Compact | +| `Inner`(들어간 화면) | Medium 이상 | 없음 | 없음 | +| `Listed`(목록이 있는 영역의 화면) | Medium 이상 | Expanded | 없음 | +| `WholeWindow`(메뉴를 끈 화면) | 없음 | 없음 | 없음 | + +5. 고정 목록이 있으면 레일의 끝에서 시작하고 본문은 목록의 끝 뒤에 있다. + +`assertSameLines`는 지금까지 본 화면들을 종류별로 묶어 본문의 시작선과 폭이 같은지 본다. `look`이 돌려주는 +값(dp로 잰 시작선, 폭, 제목 시작, 메뉴 유무)은 앱이 증거 파일로 남길 수 있다. 정산나우의 +`everyScreenStartsOnOneLineAndTheFrameKeepsTheMenusThisWindowSizeCallsFor`를 일반화한 것이다. + +### 9.3 DDS 자체 테스트 + +모두 실패하는 것을 먼저 확인한 뒤에 통과시킨다. + +**단위 테스트** + +- 창 폭 경계: 599는 Compact, 600은 Medium, 839는 Medium, 840은 Expanded. +- 옆 여백: Compact 16, Medium 24, Expanded 24. +- 생성된 숫자가 `tokens/native.json`과 같다(기존 검사). + +**기기 테스트** (에뮬레이터. 창 크기를 테스트 안에서 강제로 바꾼다: 475×751, 704×933, 933×704) + +- 창 폭마다 맞는 메뉴가 있고, `navigation = false`이면 어느 폭에서도 없다. +- 탭 화면, 들어간 화면, 목록 옆 화면의 본문 시작선과 폭이 종류별로 같다. 정산나우가 펼친 가로에서 잰 + 배치(레일 80, 목록 264 + 선, 본문 가운데)가 나온다. +- 상단 줄: 화살표 그림과 마지막 아이콘 그림이 본문 선에 서고, 제목 시작이 로고 뒤와 화살표 뒤에서 같다. +- 두 줄 제목: 긴 이름이 `barTitles`에 있으면 모든 상단 줄이 104가 되고, 없으면 64다. +- 가운데 묶음: 가운데에 있고 폭이 400을 넘지 않는다. 글자를 키워 넘치게 하면 맨 위에서 시작한다. +- 화면이 바뀌는 동안(시계를 멈추고 프레임마다) 레일의 자리와 픽셀이 그대로다. +- 창 폭 분류를 바꿔도 화면 안의 상태(스크롤 위치, 입력한 글자)가 유지된다. +- 하단 탭: Compact의 최상위 화면에만 있고, 상세 화면이 그 위로 나타나는 동안 비는 프레임이 없다. +- 글자 200%와 큰 글자 모드: 레일·하단 탭 라벨이 두 줄로 다 보이고, 상단 줄 제목이 잘리지 않는다. +- 화면 낭독기: 목록 줄이 선택 상태와 순서를 말하고, 가운데 제목이 제목 하나로 읽힌다. +- 오른쪽에서 왼쪽: 레일과 목록이 오른쪽에 온다. + +### 9.4 눈으로 확인 + +카탈로그 앱을 새 틀로 옮기고(탭이 이미 넷이다) 시연을 넣는다: 본문 기둥, 고정 목록이 있는 영역, 창 전체를 +쓰는 화면, 로그인 전 모양의 가운데 묶음. 에뮬레이터에서 창 세 가지 × 밝은·어두운 화면 × 보통·큰 글자로 +캡처해 직접 본다. 캡처 위에 본문 시작선과 끝선을 그어 상단 줄의 그림과 맞는지 본다. + +실물 폰은 DDS 쪽에서 건드리지 않는다. 정산나우가 옮긴 뒤 창 세 가지에서 확인한다. + +## 10. 호환성과 옮기는 방법 + +기존 코드는 고치지 않아도 컴파일된다. 버전은 0.7.0이다. 눈에 보이게 바뀌는 것: + +| 부품 | 바뀌는 것 | +| --- | --- | +| `DdsTopBar` | 넓은 창에서 내용이 본문 기둥 폭에 모인다. 옆 여백이 600 이상에서 16 → 24가 된다. 로고 뒤 제목이 `g + 40`에서 시작한다 | +| `DdsBottomBar`, `DdsNavRail` | 라벨이 한 줄에서 잘리지 않고 두 줄까지 쓴다. 그만큼 높아질 수 있다. 레일 폭이 80으로 고정된다 | + +**정산나우** (그쪽 레포에서 그쪽 세션이 한다. DDS 쪽은 안내 메모만 쓴다) + +| 정산나우 | DDS 0.7.0 | +| --- | --- | +| `JnWidth`, `jnWidth()` | `DdsWindowWidth`, `ddsWindowWidth()` | +| 틀 블록(`BoxWithConstraints` + `Row`), `JnRail` | `DdsAppFrame` | +| `JnFrame`, `LocalJnFrame` | `DdsFrame`, `ddsFrame()` | +| 탭 화면의 `NavigationBar` | `DdsScaffold(bottomTabs = true)` | +| `jnBody()`, `JnBodyColumn`, `widthIn(max = 680).padding(20)` | `ddsBody()`, `DdsBodyColumn`, `ddsBodyLines()` | +| `JnTopBar`, `jnBarNeedsTwoLines` | `DdsTopBar`, `barTitles` 또는 `ddsBarNeedsTwoLines` | +| `JnGateColumn`, `JnGateTitle` | `DdsCenteredColumn`, `DdsCenteredTitle`(한 줄씩 올라오는 화면은 당분간 자기 제목) | +| `BusinessSectionPane`의 줄 | `DdsPaneList`, `DdsPaneItem` | +| 옆 여백 20 | 16 또는 24 | +| 직접 짠 측정 테스트 | `DdsFrameCheck` | + +정산나우에서 눈에 보이게 달라지는 것: 옆 여백(접으면 4 줄고 펼치면 4 늘어난다), 상단 줄 아래 선이 항상 +보인다, 본문 위쪽 여백이 12 또는 20에서 16 또는 24가 된다. + +**Qooroo.** 탭이 없으므로 틀은 쓰지 않는다. 원하면 `ddsWindowWidth`, 옆 여백, `ddsBody(max)`만 쓴다. + +## 11. 범위 밖 + +- 목록과 상세를 반반으로 나누는 두 칸(Qooroo의 방식). +- 반쯤 접은 자세와 접히는 선을 피하는 배치. +- 서랍형 메뉴. +- 단계로 하는 화면의 틀(정산나우 `JnWizardFrame`). 이 설계의 부품 위에 얹히므로 다음 설계로 한다. + `barReserve`는 그때를 위한 자리다. +- 모션 요청 두 건: 스플래시 로고가 가는 길을 화면이 고르기, 화면 덩어리가 차례로 도착하는 값. +- 입력칸 요청 세 건: 글자 사본을 갖지 않는 입력칸, 칸 묶음을 통째로 보이게 끌어오기, 여러 줄. +- `DdsMenu`의 그림자와 끝 맞춤(Qooroo 요청). 0.6.0에 실었다(그림자 없음, `DdsMenuAlign.End`, `DdsMenuDivider`, D-053). + +## 12. 버린 대안 + +**Material의 적응형 틀을 감싼다.** Google이 유지하고 접힘 자세를 안다. 그러나 모든 앱에 새 의존성이 붙고, +목록 폭과 칸이 바뀌는 움직임을 그쪽 규칙으로 정하므로 264 같은 숫자와 D-051의 전환 규칙에 맞추려면 +덮어써야 한다. 이번 문제(시작선, 상단 줄과 본문의 어긋남)는 풀어 주지 않는다. 정산나우는 이동 구조를 다시 +짜야 한다. 0.5.0에서 Material의 스크롤 탭 줄이 통제할 수 없는 방식으로 동작해 직접 그린 일도 있었다. + +**숫자와 작은 도우미만 준다.** 가장 작다. 그러나 원인이 그대로 남는다. 숫자를 지키게 하는 부품이 없어서 +화면마다 갈라졌던 것이고, 앱이 둘이면 틀도 둘로 갈라진다. 다만 이 안의 조각들(창 폭 분류, 여백, 본문 +기둥)은 따로도 쓸 수 있게 만들어 탭이 없는 앱이 가져다 쓴다. + +**하단 탭도 틀이 그린다.** 대칭이라 단순하지만 상세 화면을 열 때 탭 줄이 전환과 따로 사라져 내용이 튄다. + +## 13. 확인하지 못한 채 설계한 것 + +구현 계획에서 먼저 확인한다. + +- 확인: Material `NavigationBar`와 레일 항목은 라벨 줄 수만큼 높아진다. 에뮬레이터(API 36)에서 라벨 폭을 60dp로 묶고 + 재었다. 글자 배율 1에서 라벨이 3줄이고 탭 줄 높이는 88dp, 레일 항목 높이도 88dp다. 배율 2에서는 라벨이 5줄이고 + 탭 줄과 레일 항목 모두 200dp까지 자란다. 두 경우 모두 라벨 노드가 줄 안에 들어 있고 글자는 넘치지 않는다 + (`hasVisualOverflow`가 거짓). `heightIn(min = 160.dp)`도 얹어 봤지만 배율 2의 자연 높이(200dp)가 이미 그보다 + 커서 아무것도 보여 주지 못한다. 그래서 Material 항목을 감싸서 쓴다. 직접 그릴 필요가 없다. + 재지 못한 것: 라벨이 정확히 두 줄인 경우와 한 줄짜리 기준선은 없다(60dp로 묶은 탓에 3줄과 5줄이 나왔다). + 라벨은 DDS의 실제 라벨이 아니라 폭을 60dp로 고정한 `Text` 하나였고, 탭 줄도 항목이 여러 개인 실제 줄이 + 아니라 항목 하나짜리였다. 첫 구현 뒤에 실제 부품으로 다시 잰다. +- 확인: `DeviceConfigurationOverride(ForcedSize(DpSize(933.dp, 704.dp)))`는 폰 크기 에뮬레이터에서도 안쪽 + `BoxWithConstraints`에 `maxWidth` 933.19dp, `maxHeight` 704dp를 준다. 오차는 1dp 안이다. 다만 이 덮어쓰기는 창이 + 아니라 밀도를 바꿔 그 크기를 화면에 맞춘다(덮어쓴 밀도는 1.15625. 실제 기기 밀도 2.625는 1080÷411로 계산한 + 값이며 따로 찍어 보지는 않았다). 그래서 창 크기는 + `BoxWithConstraints`로만 읽어야 한다. `LocalWindowInfo.current.containerSize`는 덮어쓰기와 무관하게 실제 창인 + 1080×2400px을 그대로 주고, 덮어쓴 밀도로 나누면 934×2076dp다. 너비는 덮어쓰기가 강제한 너비가 창에 맞도록 + 밀도를 정하므로 맞게 나오는 것이고(그래서 933이 아니라 934로 반올림 차이만 난다), 높이는 강제한 704가 + 아니라 실제 창 높이라 틀린다. + `LocalConfiguration.current.screenWidthDp`도 덮어쓰이지 않아 411을 준다(높이 838). 테스트에서 창 크기를 + 강제하는 방법은 `ForcedSize`를 쓴다. `requiredSize` 우회는 필요 없다. + 재지 못한 것: 933×704 한 가지만 돌렸다. 세로 방향 704×933과 475×751은 돌리지 않았다. +- 릴리스 워크플로가 세 번째 꾸러미를 함께 올리는지. +- 고정 목록이 있을 때 화면의 제목과 목록의 제목을 검사가 구분하는 방법(본문 기둥의 좌우 안에 있는 제목을 + 화면의 것으로 본다). +- 분할 화면처럼 앱 창이 화면보다 작을 때 틀 밖의 `ddsFrame()`이 읽는 폭. diff --git a/scripts/generate-android-tokens.mjs b/scripts/generate-android-tokens.mjs index 09ba4aa6..dfdacd97 100644 --- a/scripts/generate-android-tokens.mjs +++ b/scripts/generate-android-tokens.mjs @@ -162,10 +162,10 @@ export function renderAndroidTokens({ light, dark, sourceSha256 }) { control: "Button heights (dp, minimums). `sm` is the visible height; its touch area is still [DdsTouch.min].", field: "Text field height (dp, minimum).", row: "List row heights (dp, minimums).", - bar: "Top bar and bottom navigation bar heights (dp).", + bar: "Top bar, bottom navigation bar and side rail sizes (dp). `rail` is the rail width; `topTall` is the tall top bar height.", icon: "Icon sizes (dp).", border: "Stroke widths (dp).", - screen: "Screen gutters, the width (dp) at which the wide gutter and rail apply, and the gap between blocks.", + screen: "Screen gutters, the widths (dp) at which the wide gutter and the side-by-side layout apply, the widest reading column and centered form, the list pane width, and the gap between blocks.", }; const dimensKt = kotlinFile( [ diff --git a/tests/android-tokens.test.mjs b/tests/android-tokens.test.mjs index 0264b2a5..cd5dacfc 100644 --- a/tests/android-tokens.test.mjs +++ b/tests/android-tokens.test.mjs @@ -70,6 +70,35 @@ test("native-only values and motion come from the token files", async () => { assert.ok(motion.includes(`val exit = DdsEasingToken(${x1}f, ${y1}f, ${x2}f, ${y2}f)`)); }); +test("the screen frame numbers are generated for Kotlin and XML", () => { + const files = generateAndroidTokens(); + const dimens = files.get(`${kotlinDir}/DdsDimens.kt`); + const screen = dimens.slice(dimens.indexOf("object DdsScreen")); + const bar = dimens.slice(dimens.indexOf("object DdsBar"), dimens.indexOf("object DdsIcon")); + const expected = [ + [screen, "breakpointExpanded", 840], + [screen, "bodyMax", 680], + [screen, "listPane", 264], + [screen, "centeredMax", 400], + [bar, "rail", 80], + [bar, "topTall", 104], + ]; + for (const [block, name, value] of expected) { + assert.match(block, new RegExp(`const val ${name}: Int = ${value}\\b`)); + } + const xml = files.get(`${resDir}/values/dds_tokens.xml`); + for (const [name, value] of [ + ["dds_screen_breakpoint_expanded", 840], + ["dds_screen_body_max", 680], + ["dds_screen_list_pane", 264], + ["dds_screen_centered_max", 400], + ["dds_bar_rail", 80], + ["dds_bar_top_tall", 104], + ]) { + assert.match(xml, new RegExp(`${value}dp`)); + } +}); + test("every generated file carries the do-not-edit header", () => { for (const [path, content] of generateAndroidTokens()) { assert.ok(content.includes(GENERATED_HEADER), `${path} is missing the generated header`); diff --git a/tokens/native.json b/tokens/native.json index bdb9b2fd..9aa8fa6b 100644 --- a/tokens/native.json +++ b/tokens/native.json @@ -44,7 +44,9 @@ }, "bar": { "top": { "$value": 64, "$type": "dimension" }, - "bottom": { "$value": 80, "$type": "dimension" } + "bottom": { "$value": 80, "$type": "dimension" }, + "rail": { "$value": 80, "$type": "dimension" }, + "top-tall": { "$value": 104, "$type": "dimension" } }, "icon": { "sm": { "$value": 20, "$type": "dimension" }, @@ -55,11 +57,15 @@ "focus": { "$value": 2, "$type": "dimension" } }, "screen": { - "$description": "gutter below the breakpoint width, gutter-wide at or above it; block-gap between stacked blocks.", + "$description": "gutter below the breakpoint width, gutter-wide at or above it; block-gap between stacked blocks. breakpoint-expanded is the width where the list and detail show side by side; body-max the widest a reading column gets; list-pane the width of the list; centered-max the widest a centered form gets.", "gutter": { "$value": 16, "$type": "dimension" }, "gutter-wide": { "$value": 24, "$type": "dimension" }, "breakpoint": { "$value": 600, "$type": "dimension" }, - "block-gap": { "$value": 24, "$type": "dimension" } + "block-gap": { "$value": 24, "$type": "dimension" }, + "breakpoint-expanded": { "$value": 840, "$type": "dimension" }, + "body-max": { "$value": 680, "$type": "dimension" }, + "list-pane": { "$value": 264, "$type": "dimension" }, + "centered-max": { "$value": 400, "$type": "dimension" } }, "large-text": { "$description": "The app-offered large text mode: the same eight styles, stepped up. It stacks with the system font scale (values are sp).",