feat(widget): 공통 탭바와 타이틀바 컴포넌트를 추가한다
This commit is contained in:
401
docs/plan-task/20260519_캡슐탭바컴포넌트.md
Normal file
401
docs/plan-task/20260519_캡슐탭바컴포넌트.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# Capsule Tab Bar 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:** Figma `20:3590` 기준으로 메뉴가 많으면 가로 스크롤되는 재사용 가능한 Capsule Tab Bar Component를 추가한다.
|
||||
|
||||
**Architecture:** 기존 `TextTabBarView`는 text 타입 탭 전용으로 유지하고, capsule 타입은 `CapsuleTabBarView`로 분리한다. XML 레이아웃은 `HorizontalScrollView`와 내부 `LinearLayout`을 제공하고, Kotlin custom view가 메뉴 TextView를 동적으로 생성해 selected 상태를 단일 관리한다.
|
||||
|
||||
**Tech Stack:** Android XML Views, Kotlin custom View, ViewBinding/resource merge, JUnit4 local unit test.
|
||||
|
||||
---
|
||||
|
||||
## 작업 목표
|
||||
- Figma `20:3590` capsule 탭바 디자인을 Android XML/Kotlin 재사용 컴포넌트로 구현한다.
|
||||
- 메뉴 개수가 많으면 가로 스크롤되어야 한다.
|
||||
- 기존 `TextTabBarView`와 기존 화면은 변경하지 않는다.
|
||||
|
||||
## 파일 구조
|
||||
- Create: `app/src/main/res/layout/view_capsule_tab_bar.xml`
|
||||
- `CapsuleTabBarView` 루트, 내부 `HorizontalScrollView`, 내부 `LinearLayout` 컨테이너를 정의한다.
|
||||
- Create: `app/src/main/res/drawable/bg_capsule_tab_selected.xml`
|
||||
- selected capsule 배경을 정의한다.
|
||||
- Create: `app/src/main/res/drawable/bg_capsule_tab_normal.xml`
|
||||
- normal capsule 배경과 border를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt`
|
||||
- 메뉴 목록과 selected index를 검증/보정하는 순수 Kotlin 상태 객체를 정의한다.
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt`
|
||||
- 메뉴 TextView를 동적으로 생성하고, 선택 상태와 콜백을 관리한다.
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt`
|
||||
- 1개 이상 메뉴, out-of-range 보정, 단일 selected 상태를 검증한다.
|
||||
- Modify: `docs/plan-task/20260519_캡슐탭바컴포넌트.md`
|
||||
- 구현 중 체크박스와 검증 기록을 누적한다.
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Task 1: Capsule tab 선택 상태 TDD
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt`
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt`
|
||||
|
||||
- [x] **Step 1: RED - 선택 상태 테스트 추가**
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.widget
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class CapsuleTabSelectionStateTest {
|
||||
|
||||
@Test
|
||||
fun `create keeps one selected tab with one or more menus`() {
|
||||
val state = CapsuleTabSelectionState.create(listOf("추천", "랭킹", "팔로잉"), selectedIndex = 2)
|
||||
|
||||
assertEquals(listOf("추천", "랭킹", "팔로잉"), state.menus)
|
||||
assertEquals(2, state.selectedIndex)
|
||||
assertFalse(state.isSelected(0))
|
||||
assertFalse(state.isSelected(1))
|
||||
assertTrue(state.isSelected(2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create rejects empty menus`() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
CapsuleTabSelectionState.create(emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create normalizes out of range selected index to first menu`() {
|
||||
val state = CapsuleTabSelectionState.create(listOf("추천", "랭킹"), selectedIndex = 5)
|
||||
|
||||
assertEquals(0, state.selectedIndex)
|
||||
assertTrue(state.isSelected(0))
|
||||
assertFalse(state.isSelected(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `select changes selected tab`() {
|
||||
val state = CapsuleTabSelectionState.create(listOf("추천", "랭킹", "팔로잉"))
|
||||
.select(1)
|
||||
|
||||
assertEquals(1, state.selectedIndex)
|
||||
assertFalse(state.isSelected(0))
|
||||
assertTrue(state.isSelected(1))
|
||||
assertFalse(state.isSelected(2))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: RED 실행**
|
||||
|
||||
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"`
|
||||
|
||||
Expected: `Unresolved reference 'CapsuleTabSelectionState'`로 실패한다.
|
||||
|
||||
- [x] **Step 3: GREEN - 최소 상태 객체 추가**
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.widget
|
||||
|
||||
class CapsuleTabSelectionState private constructor(
|
||||
val menus: List<String>,
|
||||
val selectedIndex: Int
|
||||
) {
|
||||
fun isSelected(index: Int): Boolean = selectedIndex == index
|
||||
|
||||
fun select(index: Int): CapsuleTabSelectionState = create(menus, index)
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
menus: List<String>,
|
||||
selectedIndex: Int = 0
|
||||
): CapsuleTabSelectionState {
|
||||
require(menus.isNotEmpty()) { "Capsule tab bar requires at least one menu." }
|
||||
|
||||
val normalizedSelectedIndex = if (selectedIndex in menus.indices) selectedIndex else 0
|
||||
return CapsuleTabSelectionState(
|
||||
menus = menus.toList(),
|
||||
selectedIndex = normalizedSelectedIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: GREEN 실행**
|
||||
|
||||
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"`
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
### Task 2: Capsule tab XML 리소스 추가
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/res/drawable/bg_capsule_tab_selected.xml`
|
||||
- Create: `app/src/main/res/drawable/bg_capsule_tab_normal.xml`
|
||||
- Create: `app/src/main/res/layout/view_capsule_tab_bar.xml`
|
||||
|
||||
- [x] **Step 1: selected capsule drawable 추가**
|
||||
|
||||
`app/src/main/res/drawable/bg_capsule_tab_selected.xml`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/soda_400" />
|
||||
<corners android:radius="100dp" />
|
||||
</shape>
|
||||
```
|
||||
|
||||
- [x] **Step 2: normal capsule drawable 추가**
|
||||
|
||||
`app/src/main/res/drawable/bg_capsule_tab_normal.xml`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="@color/black" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/gray_700" />
|
||||
<corners android:radius="100dp" />
|
||||
</shape>
|
||||
```
|
||||
|
||||
- [x] **Step 3: capsule tab bar layout 추가**
|
||||
|
||||
`app/src/main/res/layout/view_capsule_tab_bar.xml`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:background="@color/black">
|
||||
|
||||
<HorizontalScrollView
|
||||
android:id="@+id/hsv_capsule_tab_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="false"
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_capsule_tab_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="@dimen/spacing_20" />
|
||||
</HorizontalScrollView>
|
||||
</kr.co.vividnext.sodalive.v2.widget.CapsuleTabBarView>
|
||||
```
|
||||
|
||||
### Task 3: CapsuleTabBarView 구현
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt`
|
||||
|
||||
- [x] **Step 1: custom view 추가**
|
||||
|
||||
```kotlin
|
||||
package kr.co.vividnext.sodalive.v2.widget
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import kr.co.vividnext.sodalive.R
|
||||
|
||||
class CapsuleTabBarView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var container: LinearLayout? = null
|
||||
private var selectionState: CapsuleTabSelectionState? = null
|
||||
private var onTabSelected: ((index: Int) -> Unit)? = null
|
||||
|
||||
override fun onFinishInflate() {
|
||||
super.onFinishInflate()
|
||||
container = findViewById(R.id.ll_capsule_tab_container)
|
||||
}
|
||||
|
||||
fun setMenus(
|
||||
menus: List<String>,
|
||||
selectedIndex: Int = 0
|
||||
) {
|
||||
applyState(CapsuleTabSelectionState.create(menus, selectedIndex), notifySelection = false)
|
||||
}
|
||||
|
||||
fun selectTab(index: Int) {
|
||||
val currentState = selectionState ?: return
|
||||
if (index !in currentState.menus.indices) return
|
||||
if (index == currentState.selectedIndex) return
|
||||
|
||||
applyState(currentState.select(index), notifySelection = true)
|
||||
}
|
||||
|
||||
fun setOnTabSelectedListener(listener: ((index: Int) -> Unit)?) {
|
||||
onTabSelected = listener
|
||||
}
|
||||
|
||||
private fun applyState(
|
||||
state: CapsuleTabSelectionState,
|
||||
notifySelection: Boolean
|
||||
) {
|
||||
selectionState = state
|
||||
val tabContainer = requireNotNull(container) { "Capsule tab container is not inflated." }
|
||||
tabContainer.removeAllViews()
|
||||
|
||||
state.menus.forEachIndexed { index, label ->
|
||||
tabContainer.addView(createTabView(label, state.isSelected(index), index))
|
||||
}
|
||||
|
||||
if (notifySelection) {
|
||||
onTabSelected?.invoke(state.selectedIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createTabView(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
index: Int
|
||||
): TextView {
|
||||
return TextView(context).apply {
|
||||
text = label
|
||||
isSelected = selected
|
||||
setTextAppearance(R.style.Typography_Body5)
|
||||
setTextColor(ContextCompat.getColor(context, R.color.white))
|
||||
background = ContextCompat.getDrawable(
|
||||
context,
|
||||
if (selected) R.drawable.bg_capsule_tab_selected else R.drawable.bg_capsule_tab_normal
|
||||
)
|
||||
gravity = android.view.Gravity.CENTER
|
||||
minHeight = resources.getDimensionPixelSize(R.dimen.spacing_32)
|
||||
setPadding(
|
||||
resources.getDimensionPixelSize(R.dimen.spacing_12),
|
||||
resources.getDimensionPixelSize(R.dimen.spacing_8),
|
||||
resources.getDimensionPixelSize(R.dimen.spacing_12),
|
||||
resources.getDimensionPixelSize(R.dimen.spacing_8)
|
||||
)
|
||||
setOnClickListener { selectTab(index) }
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
resources.getDimensionPixelSize(R.dimen.spacing_32)
|
||||
).apply {
|
||||
if (index > 0) {
|
||||
marginStart = resources.getDimensionPixelSize(R.dimen.spacing_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: 리소스 참조 확인**
|
||||
|
||||
Run: `rg -n "CapsuleTabBarView|ll_capsule_tab_container|bg_capsule_tab|Typography_Body5|spacing_8|spacing_12|spacing_32" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive/v2/widget`
|
||||
|
||||
Expected: 새 layout/drawable/Kotlin 파일의 참조가 모두 조회된다.
|
||||
|
||||
### Task 4: 검증 및 문서 기록
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plan-task/20260519_캡슐탭바컴포넌트.md`
|
||||
|
||||
- [x] **Step 1: LSP 진단 실행**
|
||||
|
||||
Run: `lsp_diagnostics` on modified Kotlin/XML files
|
||||
|
||||
Expected: 새 오류가 없다. Kotlin/XML LSP가 환경에 없으면 검증 기록에 남긴다.
|
||||
|
||||
- [x] **Step 2: 단위 테스트 실행**
|
||||
|
||||
Run: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"`
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [x] **Step 3: 리소스 빌드 실행**
|
||||
|
||||
Run: `./gradlew :app:assembleDebug`
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [x] **Step 4: 스타일 검사 실행**
|
||||
|
||||
Run: `./gradlew :app:ktlintCheck`
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [x] **Step 5: 검증 기록 누적**
|
||||
|
||||
문서 하단 `검증 기록`에 실행한 명령, 결과, 빌드 성공 여부를 한국어로 기록한다.
|
||||
|
||||
## 체크리스트
|
||||
- [x] AC1: `view_capsule_tab_bar.xml`은 height `52dp`, background black을 가진다.
|
||||
- QA: XML 속성 확인
|
||||
- [x] AC2: `view_capsule_tab_bar.xml`은 `HorizontalScrollView`를 포함해 메뉴 초과 시 가로 스크롤 가능하다.
|
||||
- QA: XML 구조와 `assembleDebug` 확인
|
||||
- [x] AC3: selected 탭은 `soda_400` 배경과 white 텍스트를 사용한다.
|
||||
- QA: `bg_capsule_tab_selected.xml`, `CapsuleTabBarView.kt` 확인
|
||||
- [x] AC4: normal 탭은 black 배경, `gray_700` border, white 텍스트를 사용한다.
|
||||
- QA: `bg_capsule_tab_normal.xml`, `CapsuleTabBarView.kt` 확인
|
||||
- [x] AC5: 메뉴 1개 이상을 허용하고 항상 하나만 selected 상태다.
|
||||
- QA: `CapsuleTabSelectionStateTest`
|
||||
- [x] AC6: 기존 `TextTabBarView`와 기존 화면은 변경하지 않는다.
|
||||
- QA: 변경 파일 목록 확인
|
||||
- [x] AC7: 리소스 병합, 단위 테스트, ktlint 검증이 성공한다.
|
||||
- QA: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck`
|
||||
|
||||
## 검증 기록
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: Figma `20:3590` 디자인을 확인하고, 기존 Text Tab과 분리된 Capsule Tab Bar PRD 및 구현 계획/TASK 문서를 작성했다.
|
||||
- 실행 명령/도구:
|
||||
- `Figma_get_design_context(20:3590)`
|
||||
- `Figma_get_screenshot(20:3590)`
|
||||
- `read(app/src/main/res/layout/view_text_tab_bar.xml)`
|
||||
- `read(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt)`
|
||||
- `read(app/src/main/res/values/colors.xml)`
|
||||
- `read(app/src/main/res/values/dimens.xml)`
|
||||
- `read(app/src/main/res/values/typography.xml)`
|
||||
- 결과:
|
||||
- PRD 문서는 `docs/prd/20260519_캡슐탭바컴포넌트_prd.md`에 작성했다.
|
||||
- 계획/TASK 문서는 `docs/plan-task/20260519_캡슐탭바컴포넌트.md`에 작성했다.
|
||||
- 구현은 아직 수행하지 않았다.
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 탭바 컴포넌트 네이밍을 `{Type}TabBarView` 방식으로 통일하기로 결정해 문서의 기존 text 탭 컴포넌트 참조를 `TextTabBarView` 계열로 갱신했다.
|
||||
- 실행 명령/도구:
|
||||
- `rg -n "CapsuleTabBarView|TabTextBarView|TabText|TextTab" app/src docs`
|
||||
- `apply_patch(docs/prd/20260519_캡슐탭바컴포넌트_prd.md)`
|
||||
- `apply_patch(docs/plan-task/20260519_캡슐탭바컴포넌트.md)`
|
||||
- 결과:
|
||||
- Capsule 컴포넌트는 `CapsuleTabBarView` 이름을 유지한다.
|
||||
- 기존 text 탭 컴포넌트 참조는 `TextTabBarView`, `TextTabSelectionState` 기준으로 정리했다.
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 계획 문서에 따라 `CapsuleTabSelectionState`를 TDD로 추가하고, `HorizontalScrollView` 기반 재사용 `CapsuleTabBarView`와 capsule 배경 리소스를 구현했다.
|
||||
- 실행 명령/도구:
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` (RED)
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"` (GREEN)
|
||||
- `rg -n "CapsuleTabBarView|ll_capsule_tab_container|bg_capsule_tab|Typography_Body5|spacing_8|spacing_12|spacing_32" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive/v2/widget`
|
||||
- `lsp_diagnostics` on modified Kotlin/XML files
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"`
|
||||
- `./gradlew :app:assembleDebug`
|
||||
- `./gradlew :app:ktlintCheck`
|
||||
- 결과:
|
||||
- RED 테스트는 `Unresolved reference 'CapsuleTabSelectionState'`로 실패해 신규 상태 객체 부재를 확인했다.
|
||||
- GREEN 테스트는 `BUILD SUCCESSFUL`로 통과했다.
|
||||
- 리소스 참조 확인에서 `CapsuleTabBarView`, `ll_capsule_tab_container`, `bg_capsule_tab_*`, `Typography_Body5`, `spacing_8`, `spacing_12`, `spacing_32` 참조를 확인했다.
|
||||
- Kotlin/XML LSP는 현재 환경에 서버가 없어 `No LSP server configured for extension: .kt/.xml`로 실행 불가했다.
|
||||
- 단위 테스트, `assembleDebug`, `ktlintCheck`는 모두 `BUILD SUCCESSFUL`로 종료했다.
|
||||
258
docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md
Normal file
258
docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# 타이틀바 및 탭 텍스트바 컴포넌트
|
||||
|
||||
## 작업 목표
|
||||
- XML 레이아웃 기반 재사용 가능한 Title Bar Component를 개발한다.
|
||||
- XML 레이아웃 기반 재사용 가능한 Tab-Text Bar Component를 개발한다.
|
||||
- 구현 범위는 컴포넌트 추가로 한정하고 기존 화면 일괄 적용은 제외한다.
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Task 1: 기존 리소스 및 유사 UI 확인
|
||||
|
||||
**Files:**
|
||||
- Read: `app/src/main/res/layout/toolbar_audio_content_main.xml`
|
||||
- Read: `app/src/main/res/layout/activity_audio_content_main.xml`
|
||||
- Read: `app/src/main/res/layout/activity_can_status.xml`
|
||||
- Read: `app/src/main/res/values/colors.xml`
|
||||
- Read: `app/src/main/res/values/typography.xml`
|
||||
|
||||
- [x] **Step 1: 기존 타이틀 바 패턴 확인**
|
||||
|
||||
Run: `rg -n "toolbar|Toolbar|title|Title" app/src/main/res/layout app/src/main/java/kr/co/vividnext/sodalive`
|
||||
|
||||
Expected: 기존 화면에서 include 또는 개별 toolbar 사용 패턴을 확인한다.
|
||||
|
||||
- [x] **Step 2: 기존 탭 바 패턴 확인**
|
||||
|
||||
Run: `rg -n "TabLayout|tabText|ContentMainTabText|color_tabbar" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive`
|
||||
|
||||
Expected: 기존 TabLayout 및 탭 텍스트 스타일 사용 패턴을 확인한다.
|
||||
|
||||
- [x] **Step 3: 필수 리소스 확인**
|
||||
|
||||
Run: `rg -n "img_text_logo_v2|gray_600|Typography\.Heading3|<color name=\"white\"|<color name=\"black\"" app/src/main/res`
|
||||
|
||||
Expected: `gray_600`, `Typography.Heading3`, black/white 색상과 로고 drawable 존재 여부를 확인한다.
|
||||
|
||||
### Task 2: Title Bar Component 추가
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/res/layout/view_title_bar_home.xml`
|
||||
- Create: `app/src/main/res/layout/view_title_bar_default.xml`
|
||||
|
||||
- [x] **Step 1: Home Title Bar XML 추가**
|
||||
|
||||
`view_title_bar_home.xml`에 높이 60dp, 전체 너비, 검정 배경, 세로 중앙 정렬을 갖는 레이아웃을 추가한다. 좌측에는 `img_text_logo_v2`, 중앙에는 가변 빈 영역, 우측에는 교체 가능한 메뉴 아이콘 `ImageView`를 둔다.
|
||||
|
||||
- [x] **Step 2: Default Title Bar XML 추가**
|
||||
|
||||
`view_title_bar_default.xml`에 높이 60dp, 전체 너비, 검정 배경, 세로 중앙 정렬을 갖는 레이아웃을 추가한다. 좌측에는 화면명 `TextView`, 중앙에는 가변 빈 영역, 우측에는 교체 가능한 메뉴 아이콘 `ImageView`를 둔다.
|
||||
|
||||
- [x] **Step 3: 호출부 커스터마이징 지점 명확화**
|
||||
|
||||
각 레이아웃의 화면명 TextView와 메뉴 ImageView에는 ViewBinding에서 접근 가능한 명확한 id를 부여한다.
|
||||
|
||||
### Task 3: Tab-Text Bar Component 추가
|
||||
|
||||
**Files:**
|
||||
- Create: `app/src/main/res/layout/view_text_tab_bar.xml`
|
||||
- Create or Modify: 필요한 경우 탭 텍스트 색상 selector 리소스
|
||||
|
||||
- [x] **Step 1: Tab-Text Bar XML 추가**
|
||||
|
||||
`view_text_tab_bar.xml`에 높이 52dp, 전체 너비, 검정 배경, 세로 중앙 정렬을 갖는 텍스트 탭 바 레이아웃을 추가한다.
|
||||
|
||||
- [x] **Step 2: Typography와 색상 적용**
|
||||
|
||||
각 탭 텍스트는 `Typography.Heading3`을 적용하고, selected는 white, normal은 `gray_600`으로 표시되도록 color selector 또는 selected 상태 처리를 구성한다.
|
||||
|
||||
- [x] **Step 3: 메뉴 개수 및 단일 선택 상태 처리 방식 구현**
|
||||
|
||||
Kotlin helper 또는 커스텀 View를 추가하는 경우 메뉴 개수는 1~3개만 허용하고, 선택 변경 시 반드시 하나의 메뉴만 selected가 되도록 구현한다. XML include만으로 충분하지 않으면 최소 범위의 Kotlin 클래스를 추가한다.
|
||||
|
||||
### Task 4: 검증 및 문서 기록
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md`
|
||||
|
||||
- [x] **Step 1: XML 및 Kotlin 진단 실행**
|
||||
|
||||
Run: `lsp_diagnostics` on modified XML/Kotlin files
|
||||
|
||||
Expected: 새 오류가 없다. XML LSP가 환경에 없으면 그 사실을 검증 기록에 남긴다.
|
||||
|
||||
- [x] **Step 2: 디버그 빌드 실행**
|
||||
|
||||
Run: `./gradlew :app:assembleDebug`
|
||||
|
||||
Expected: `BUILD SUCCESSFUL`
|
||||
|
||||
- [x] **Step 3: 수동 QA 수행**
|
||||
|
||||
컴포넌트를 임시 또는 대상 화면에서 inflate 가능한지 확인한다. 실제 화면 적용이 이번 범위에 포함되지 않으면 리소스 빌드 성공과 ViewBinding 접근 id 생성 여부를 확인한다.
|
||||
|
||||
- [x] **Step 4: 검증 기록 누적**
|
||||
|
||||
문서 하단 `검증 기록`에 실행한 명령, 결과, 빌드 성공 여부를 한국어로 기록한다.
|
||||
|
||||
## 체크리스트
|
||||
- [x] AC1: Home Title Bar는 width `match_parent`, height `60dp`, black background, vertical center alignment를 가진다.
|
||||
- QA: `view_title_bar_home.xml` 속성 확인
|
||||
- [x] AC2: Home Title Bar는 좌측 `img_text_logo_v2`, 중앙 가변 빈 영역, 우측 메뉴 아이콘 구조를 가진다.
|
||||
- QA: `view_title_bar_home.xml` 구조 및 id 확인
|
||||
- [x] AC3: Default Title Bar는 width `match_parent`, height `60dp`, black background, vertical center alignment를 가진다.
|
||||
- QA: `view_title_bar_default.xml` 속성 확인
|
||||
- [x] AC4: Default Title Bar는 화면명 텍스트와 우측 메뉴 아이콘을 호출 화면에서 변경할 수 있다.
|
||||
- QA: ViewBinding 접근 가능한 id 확인
|
||||
- [x] AC5: Tab-Text Bar는 width `match_parent`, height `52dp`, black background, vertical center alignment를 가진다.
|
||||
- QA: `view_text_tab_bar.xml` 속성 확인
|
||||
- [x] AC6: Tab-Text Bar 텍스트는 `Typography.Heading3`, normal `gray_600`, selected white를 사용한다.
|
||||
- QA: XML style/color selector 또는 Kotlin 상태 처리 확인
|
||||
- [x] AC7: Tab-Text Bar 메뉴 개수는 최대 3개이며, 항상 하나만 selected 상태다.
|
||||
- QA: 선택 상태 변경 로직 또는 사용 계약 확인
|
||||
- [x] AC8: 기존 화면에는 요청 없이 일괄 적용하지 않는다.
|
||||
- QA: 변경 파일 목록에서 기존 화면 레이아웃 변경 여부 확인
|
||||
- [x] AC9: 리소스 병합 및 디버그 빌드가 성공한다.
|
||||
- QA: `./gradlew :app:assembleDebug`
|
||||
|
||||
## 검증 기록
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 사용자 요청에 따라 구현 없이 PRD와 구현 계획/TASK 문서만 작성했다. 기존 프로젝트가 XML Views + ViewBinding 중심이며, 구현 전 문서가 필요하다는 저장소 규칙을 반영했다.
|
||||
- 실행 명령/도구:
|
||||
- `read(docs/prd/sample-prd.md)`
|
||||
- `read(docs/agent-guides/workflow-docs-commits.md)`
|
||||
- `read(docs/agent-guides/code-style.md)`
|
||||
- `read(docs/agent-guides/build-test-style.md)`
|
||||
- `read(docs/prd/20260515_design_token_xml_resource_prd.md)`
|
||||
- `read(docs/prd/20260515_typography_xml_style_prd.md)`
|
||||
- `explore` agent result for existing UI patterns
|
||||
- `Figma_get_design_context(20:3575, 20:3576, 20:3585)`
|
||||
- 결과:
|
||||
- PRD 문서는 `docs/prd/20260519_타이틀바및탭텍스트바컴포넌트_prd.md`에 작성했다.
|
||||
- 계획/TASK 문서는 `docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md`에 작성했다.
|
||||
- Figma 조회는 타임아웃되어 제공된 텍스트 요구사항을 기준으로 문서화했다.
|
||||
- 코드, 리소스, 레이아웃 구현 파일은 변경하지 않았다.
|
||||
- 무엇/왜/어떻게: 계획에 따라 재사용 가능한 XML Title Bar 2종, Text Tab Bar, 전용 텍스트 색상 selector, `TextTabSelectionState` 최소 상태 helper와 이를 사용하는 `TextTabBarView`를 추가했다. 기존 화면에는 적용하지 않았다.
|
||||
- 실행 명령/도구:
|
||||
- `read(docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md)`
|
||||
- `read(app/src/main/res/layout/toolbar_audio_content_main.xml)`
|
||||
- `read(app/src/main/res/layout/activity_audio_content_main.xml)`
|
||||
- `read(app/src/main/res/layout/activity_can_status.xml)`
|
||||
- `read(app/src/main/res/values/colors.xml)`
|
||||
- `read(app/src/main/res/values/typography.xml)`
|
||||
- `rg -n "toolbar|Toolbar|title|Title" app/src/main/res/layout app/src/main/java/kr/co/vividnext/sodalive`
|
||||
- `rg -n "TabLayout|tabText|ContentMainTabText|color_tabbar" app/src/main/res app/src/main/java/kr/co/vividnext/sodalive`
|
||||
- `rg -n "img_text_logo_v2|gray_600|Typography\.Heading3|<color name=\"white\"|<color name=\"black\"" app/src/main/res`
|
||||
- `lsp_diagnostics` on modified XML/Kotlin files
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.TextTabSelectionStateTest"`
|
||||
- `./gradlew :app:assembleDebug`
|
||||
- 결과:
|
||||
- Kotlin/XML LSP 서버가 현재 환경에 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했다.
|
||||
- `TextTabSelectionStateTest`는 성공했다.
|
||||
- `:app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료되어 리소스 병합과 ViewBinding 생성 가능성을 확인했다.
|
||||
- Gradle deprecation 안내는 기존 빌드 환경 메시지로 남아 있다.
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 구현 결과를 재검토한 뒤, Text Tab Bar가 실제 View 선택 상태를 컴포넌트 내부에서 보장하도록 `view_text_tab_bar.xml` 루트를 `TextTabBarView`로 보완했다. 기존 화면 적용은 하지 않았다.
|
||||
- 실행 명령/도구:
|
||||
- `read(app/src/main/res/layout/view_title_bar_home.xml)`
|
||||
- `read(app/src/main/res/layout/view_title_bar_default.xml)`
|
||||
- `read(app/src/main/res/layout/view_text_tab_bar.xml)`
|
||||
- `read(app/src/main/res/color/color_text_tab_bar.xml)`
|
||||
- `read(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionState.kt)`
|
||||
- `read(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt)`
|
||||
- `read(app/src/test/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionStateTest.kt)`
|
||||
- `lsp_diagnostics` on modified XML/Kotlin files
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.TextTabSelectionStateTest"`
|
||||
- `./gradlew :app:assembleDebug`
|
||||
- `./gradlew :app:ktlintCheck`
|
||||
- `rg --files app/build/generated/data_binding_base_class_source_out/debug/out | rg "View(TextTabBar|TitleBar)"`
|
||||
- 결과:
|
||||
- TDD RED 단계에서 `TextTabSelectionState` 미정의로 `TextTabSelectionStateTest`가 실패함을 먼저 확인했다.
|
||||
- Kotlin/XML LSP 서버가 현재 환경에 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했다.
|
||||
- `TextTabSelectionStateTest`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `:app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `:app:ktlintCheck`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 탭바 컴포넌트 네이밍을 `CapsuleTabBarView` 방식에 맞춰 `TextTabBarView` 계열로 통일했다.
|
||||
- 실행 명령/도구:
|
||||
- `rg -n "TabText|tab_text|Tab Text|Tab-Text|view_tab_text_bar|color_tab_text_bar" app/src docs`
|
||||
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TabTextBarView.kt -> TextTabBarView.kt)`
|
||||
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TabTextSelectionState.kt -> TextTabSelectionState.kt)`
|
||||
- `apply_patch(app/src/test/java/kr/co/vividnext/sodalive/v2/widget/TabTextSelectionStateTest.kt -> TextTabSelectionStateTest.kt)`
|
||||
- `apply_patch(app/src/main/res/layout/view_tab_text_bar.xml -> view_text_tab_bar.xml)`
|
||||
- `apply_patch(app/src/main/res/color/color_tab_text_bar.xml -> color_text_tab_bar.xml)`
|
||||
- 결과:
|
||||
- `TabTextBarView`는 `TextTabBarView`로 변경했다.
|
||||
- `TabTextSelectionState`는 `TextTabSelectionState`로 변경했다.
|
||||
- `TabTextSelectionStateTest`는 `TextTabSelectionStateTest`로 변경했다.
|
||||
- `view_tab_text_bar.xml`은 `view_text_tab_bar.xml`로 변경했다.
|
||||
- `color_tab_text_bar.xml`은 `color_text_tab_bar.xml`로 변경했다.
|
||||
- Text Tab 내부 view id도 `tv_text_tab_first`, `tv_text_tab_second`, `tv_text_tab_third`로 정리했다.
|
||||
- 추가 검증:
|
||||
- Kotlin/XML LSP 서버가 현재 환경에 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했다.
|
||||
- 병렬 Gradle 실행 중 Kotlin compile 캐시 접근 오류가 한 번 발생해 순차 실행으로 재검증했다.
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.TextTabSelectionStateTest"`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `./gradlew :app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `./gradlew :app:ktlintCheck`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `ViewTextTabBarBinding`, `ViewTitleBarDefaultBinding`, `ViewTitleBarHomeBinding` 생성 파일을 확인해 신규 컴포넌트 ViewBinding 생성 가능성을 확인했다.
|
||||
- `git status --short` 기준 기존 화면 Activity/Fragment/Layout 파일은 변경하지 않았다.
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 코드 리뷰 피드백을 검토해 숨김 탭의 selected 상태가 남을 가능성을 제거하고, 이미 선택된 탭 재선택 시 중복 콜백을 발생시키지 않도록 보완했다.
|
||||
- 실행 명령/도구:
|
||||
- `oracle` read-only code review
|
||||
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt)`
|
||||
- 결과:
|
||||
- `TextTabBarView`는 메뉴가 줄어들어 숨겨지는 탭에도 `isSelected = false`를 적용한다.
|
||||
- `TextTabBarView.selectTab()`은 이미 선택된 index를 다시 선택하면 상태와 콜백을 변경하지 않는다.
|
||||
- `view_title_bar_home.xml`은 `@drawable/img_text_logo_v2`를 참조하며, 현재 해당 이미지 파일은 git 기준 untracked 상태이므로 커밋 시 함께 포함해야 한다.
|
||||
- 추가 검증:
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.TextTabSelectionStateTest"`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `./gradlew :app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `./gradlew :app:ktlintCheck`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 추가 요청에 따라 TitleBar 좌우 padding을 20dp로 지정하고, Tab-Text Bar 텍스트를 왼쪽 정렬 및 텍스트 사이 20dp 간격 구조로 변경했다.
|
||||
- 실행 명령/도구:
|
||||
- `read(app/src/main/res/layout/view_title_bar_home.xml)`
|
||||
- `read(app/src/main/res/layout/view_title_bar_default.xml)`
|
||||
- `read(app/src/main/res/layout/view_text_tab_bar.xml)`
|
||||
- `apply_patch(app/src/main/res/layout/view_title_bar_home.xml)`
|
||||
- `apply_patch(app/src/main/res/layout/view_title_bar_default.xml)`
|
||||
- `apply_patch(app/src/main/res/layout/view_text_tab_bar.xml)`
|
||||
- `lsp_diagnostics` on modified XML files
|
||||
- `rg -n "paddingHorizontal=\"20dp\"|gravity=\"start\|center_vertical\"|layout_marginEnd=\"20dp\"|layout_width=\"wrap_content\"" app/src/main/res/layout/view_title_bar_home.xml app/src/main/res/layout/view_title_bar_default.xml app/src/main/res/layout/view_text_tab_bar.xml`
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.TextTabSelectionStateTest"`
|
||||
- `./gradlew :app:assembleDebug`
|
||||
- `./gradlew :app:ktlintCheck`
|
||||
- 결과:
|
||||
- `view_title_bar_home.xml`, `view_title_bar_default.xml` 루트에 `android:paddingHorizontal="20dp"`를 적용했다.
|
||||
- `view_text_tab_bar.xml` 루트 gravity를 `start|center_vertical`로 변경했다.
|
||||
- Tab-Text Bar의 각 TextView는 `wrap_content` 폭을 사용하고, 첫 번째/두 번째 텍스트에 `android:layout_marginEnd="20dp"`를 적용해 텍스트 사이 간격을 20dp로 맞췄다.
|
||||
- XML LSP 서버가 현재 환경에 설정되어 있지 않아 `lsp_diagnostics`는 실행 불가했다.
|
||||
- `TextTabSelectionStateTest`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `:app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `:app:ktlintCheck`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- 2026-05-19
|
||||
- 무엇/왜/어떻게: 추가 요청에 따라 이번에 추가한 컴포넌트 XML의 하드코딩 color/spacing/radius 값을 점검하고, 기존 `colors.xml`, `dimens.xml`에 정의된 값으로 변경 가능한 항목을 치환했다.
|
||||
- 실행 명령/도구:
|
||||
- `read(app/src/main/res/layout/view_title_bar_home.xml)`
|
||||
- `read(app/src/main/res/layout/view_title_bar_default.xml)`
|
||||
- `read(app/src/main/res/layout/view_text_tab_bar.xml)`
|
||||
- `read(app/src/main/res/color/color_text_tab_bar.xml)`
|
||||
- `read(app/src/main/res/values/colors.xml)`
|
||||
- `read(app/src/main/res/values/dimens.xml)`
|
||||
- `apply_patch(app/src/main/res/layout/view_title_bar_home.xml)`
|
||||
- `apply_patch(app/src/main/res/layout/view_title_bar_default.xml)`
|
||||
- `apply_patch(app/src/main/res/layout/view_text_tab_bar.xml)`
|
||||
- `rg -n "\b[0-9]+dp\b|#[0-9A-Fa-f]{6,8}" app/src/main/res/layout/view_title_bar_home.xml app/src/main/res/layout/view_title_bar_default.xml app/src/main/res/layout/view_text_tab_bar.xml app/src/main/res/color/color_text_tab_bar.xml`
|
||||
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.TextTabSelectionStateTest"`
|
||||
- `./gradlew :app:assembleDebug`
|
||||
- `./gradlew :app:ktlintCheck`
|
||||
- 결과:
|
||||
- `20dp`로 하드코딩되어 있던 TitleBar horizontal padding, Tab-Text Bar horizontal padding, Tab-Text 간격을 `@dimen/spacing_20`으로 변경했다.
|
||||
- Color 값은 이미 `@color/black`, `@color/white`, `@color/gray_600`, `@color/color_text_tab_bar`를 사용하고 있어 추가 치환할 하드코딩 hex 값은 없었다.
|
||||
- Radius 값은 이번 컴포넌트에 없었다.
|
||||
- 남아 있는 `60dp`, `52dp`는 컴포넌트 고정 높이 요구사항 값이며 현재 `dimens.xml`에 대응 토큰이 없어 변경하지 않았다.
|
||||
- spacer용 `0dp`는 `layout_weight` 사용을 위한 Android 레이아웃 관례 값이므로 리소스로 치환하지 않았다.
|
||||
- `TextTabSelectionStateTest`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `:app:assembleDebug`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
- `:app:ktlintCheck`는 `BUILD SUCCESSFUL`로 완료됐다.
|
||||
Reference in New Issue
Block a user