diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt new file mode 100644 index 00000000..1a4db069 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt @@ -0,0 +1,97 @@ +package kr.co.vividnext.sodalive.v2.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +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, + 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 = 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) + } + } + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt new file mode 100644 index 00000000..4b30480d --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionState.kt @@ -0,0 +1,25 @@ +package kr.co.vividnext.sodalive.v2.widget + +class CapsuleTabSelectionState private constructor( + val menus: List, + val selectedIndex: Int +) { + fun isSelected(index: Int): Boolean = selectedIndex == index + + fun select(index: Int): CapsuleTabSelectionState = create(menus, index) + + companion object { + fun create( + menus: List, + 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 + ) + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt new file mode 100644 index 00000000..970a7a45 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt @@ -0,0 +1,70 @@ +package kr.co.vividnext.sodalive.v2.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import kr.co.vividnext.sodalive.R + +class TextTabBarView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val tabViews: List + get() = listOf( + findViewById(R.id.tv_text_tab_first), + findViewById(R.id.tv_text_tab_second), + findViewById(R.id.tv_text_tab_third) + ) + + private var selectionState: TextTabSelectionState? = null + private var onTabSelected: ((index: Int) -> Unit)? = null + + override fun onFinishInflate() { + super.onFinishInflate() + tabViews.forEachIndexed { index, textView -> + textView.setOnClickListener { selectTab(index) } + } + } + + fun setMenus( + menus: List, + selectedIndex: Int = 0 + ) { + applyState(TextTabSelectionState.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: TextTabSelectionState, + notifySelection: Boolean + ) { + selectionState = state + tabViews.forEachIndexed { index, textView -> + val hasMenu = index in state.menus.indices + textView.visibility = if (hasMenu) View.VISIBLE else View.GONE + textView.isSelected = hasMenu && state.isSelected(index) + if (hasMenu) { + textView.text = state.menus[index] + } + } + + if (notifySelection) { + onTabSelected?.invoke(state.selectedIndex) + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionState.kt b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionState.kt new file mode 100644 index 00000000..d4f16d22 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionState.kt @@ -0,0 +1,27 @@ +package kr.co.vividnext.sodalive.v2.widget + +class TextTabSelectionState private constructor( + val menus: List, + val selectedIndex: Int +) { + fun isSelected(index: Int): Boolean = selectedIndex == index + + fun select(index: Int): TextTabSelectionState = create(menus, index) + + companion object { + private const val MAX_MENU_COUNT = 3 + + fun create( + menus: List, + selectedIndex: Int = 0 + ): TextTabSelectionState { + require(menus.size in 1..MAX_MENU_COUNT) { "Text tab bar requires one to three menus." } + + val normalizedSelectedIndex = if (selectedIndex in menus.indices) selectedIndex else 0 + return TextTabSelectionState( + menus = menus.toList(), + selectedIndex = normalizedSelectedIndex + ) + } + } +} diff --git a/app/src/main/res/color/color_text_tab_bar.xml b/app/src/main/res/color/color_text_tab_bar.xml new file mode 100644 index 00000000..5cee8f86 --- /dev/null +++ b/app/src/main/res/color/color_text_tab_bar.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable-mdpi/ic_bar_bell.png b/app/src/main/res/drawable-mdpi/ic_bar_bell.png new file mode 100644 index 00000000..2490d6bc Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bar_bell.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bar_cash.png b/app/src/main/res/drawable-mdpi/ic_bar_cash.png new file mode 100644 index 00000000..43dd80b1 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bar_cash.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bar_search.png b/app/src/main/res/drawable-mdpi/ic_bar_search.png new file mode 100644 index 00000000..d3da8e4b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bar_search.png differ diff --git a/app/src/main/res/drawable-mdpi/img_text_logo_v2.png b/app/src/main/res/drawable-mdpi/img_text_logo_v2.png new file mode 100644 index 00000000..c6d7135f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/img_text_logo_v2.png differ diff --git a/app/src/main/res/drawable/bg_capsule_tab_normal.xml b/app/src/main/res/drawable/bg_capsule_tab_normal.xml new file mode 100644 index 00000000..5c969037 --- /dev/null +++ b/app/src/main/res/drawable/bg_capsule_tab_normal.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_capsule_tab_selected.xml b/app/src/main/res/drawable/bg_capsule_tab_selected.xml new file mode 100644 index 00000000..edc8e6b0 --- /dev/null +++ b/app/src/main/res/drawable/bg_capsule_tab_selected.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/view_capsule_tab_bar.xml b/app/src/main/res/layout/view_capsule_tab_bar.xml new file mode 100644 index 00000000..48e7b845 --- /dev/null +++ b/app/src/main/res/layout/view_capsule_tab_bar.xml @@ -0,0 +1,23 @@ + + + + + + + + diff --git a/app/src/main/res/layout/view_text_tab_bar.xml b/app/src/main/res/layout/view_text_tab_bar.xml new file mode 100644 index 00000000..4c62de49 --- /dev/null +++ b/app/src/main/res/layout/view_text_tab_bar.xml @@ -0,0 +1,43 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/view_title_bar_default.xml b/app/src/main/res/layout/view_title_bar_default.xml new file mode 100644 index 00000000..476d7d5f --- /dev/null +++ b/app/src/main/res/layout/view_title_bar_default.xml @@ -0,0 +1,32 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/view_title_bar_home.xml b/app/src/main/res/layout/view_title_bar_home.xml new file mode 100644 index 00000000..6787bdc0 --- /dev/null +++ b/app/src/main/res/layout/view_title_bar_home.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt new file mode 100644 index 00000000..37bbc4b9 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt @@ -0,0 +1,48 @@ +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)) + } +} diff --git a/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionStateTest.kt b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionStateTest.kt new file mode 100644 index 00000000..fca5e556 --- /dev/null +++ b/app/src/test/java/kr/co/vividnext/sodalive/v2/widget/TextTabSelectionStateTest.kt @@ -0,0 +1,51 @@ +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 TextTabSelectionStateTest { + + @Test + fun `create keeps one selected tab within one to three menus`() { + val state = TextTabSelectionState.create(listOf("홈", "랭킹", "마이"), selectedIndex = 1) + + assertEquals(listOf("홈", "랭킹", "마이"), state.menus) + assertEquals(1, state.selectedIndex) + assertFalse(state.isSelected(0)) + assertTrue(state.isSelected(1)) + assertFalse(state.isSelected(2)) + } + + @Test + fun `create rejects empty or more than three menus`() { + assertThrows(IllegalArgumentException::class.java) { + TextTabSelectionState.create(emptyList()) + } + assertThrows(IllegalArgumentException::class.java) { + TextTabSelectionState.create(listOf("1", "2", "3", "4")) + } + } + + @Test + fun `create normalizes out of range selected index to first menu`() { + val state = TextTabSelectionState.create(listOf("홈", "랭킹"), selectedIndex = 3) + + assertEquals(0, state.selectedIndex) + assertTrue(state.isSelected(0)) + assertFalse(state.isSelected(1)) + } + + @Test + fun `select changes selected tab and keeps only one selected`() { + val state = TextTabSelectionState.create(listOf("홈", "랭킹", "마이")) + .select(2) + + assertEquals(2, state.selectedIndex) + assertFalse(state.isSelected(0)) + assertFalse(state.isSelected(1)) + assertTrue(state.isSelected(2)) + } +} diff --git a/docs/plan-task/20260519_캡슐탭바컴포넌트.md b/docs/plan-task/20260519_캡슐탭바컴포넌트.md new file mode 100644 index 00000000..8757ce16 --- /dev/null +++ b/docs/plan-task/20260519_캡슐탭바컴포넌트.md @@ -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, + val selectedIndex: Int +) { + fun isSelected(index: Int): Boolean = selectedIndex == index + + fun select(index: Int): CapsuleTabSelectionState = create(menus, index) + + companion object { + fun create( + menus: List, + 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 + + + + + +``` + +- [x] **Step 2: normal capsule drawable 추가** + +`app/src/main/res/drawable/bg_capsule_tab_normal.xml` + +```xml + + + + + + +``` + +- [x] **Step 3: capsule tab bar layout 추가** + +`app/src/main/res/layout/view_capsule_tab_bar.xml` + +```xml + + + + + + + + +``` + +### 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, + 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`로 종료했다. diff --git a/docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md b/docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md new file mode 100644 index 00000000..07becd27 --- /dev/null +++ b/docs/plan-task/20260519_타이틀바및탭텍스트바컴포넌트.md @@ -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| 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`로 완료됐다. diff --git a/docs/prd/20260519_캡슐탭바컴포넌트_prd.md b/docs/prd/20260519_캡슐탭바컴포넌트_prd.md new file mode 100644 index 00000000..27392e77 --- /dev/null +++ b/docs/prd/20260519_캡슐탭바컴포넌트_prd.md @@ -0,0 +1,105 @@ +# PRD: 캡슐 탭바 컴포넌트 + +## 1. Overview +Figma `20:3590` 디자인을 기준으로 메뉴 개수가 많을 때 가로 스크롤되는 재사용 가능한 Capsule Tab Bar Component를 개발한다. + +--- + +## 2. Problem +- 기존 `TextTabBarView`는 텍스트 탭 전용이며 최대 3개 메뉴를 전제로 한다. +- Figma `20:3590`은 capsule 형태 탭이 여러 개 나열되고, 메뉴가 화면 너비를 넘으면 가로 스크롤되어야 한다. +- 기존 Text Tab 요구사항과 capsule 탭의 높이, 패딩, radius, border, 스크롤 동작이 달라 같은 컴포넌트에 합치면 변경 범위와 회귀 위험이 커진다. + +--- + +## 3. Goals +- 검정 배경, 높이 52dp의 재사용 가능한 Capsule Tab Bar를 제공한다. +- 탭 항목은 capsule 형태로 표시한다. +- 선택된 탭은 `soda_400` 배경과 white 텍스트로 표시한다. +- 선택되지 않은 탭은 black 배경, `gray_700` border, white 텍스트로 표시한다. +- 탭 내부 텍스트는 14sp medium 계열 Typography를 사용한다. +- 탭이 많아 화면 너비를 넘으면 가로로 스크롤되어야 한다. +- 메뉴 개수는 1개 이상을 허용하고, 항상 하나의 메뉴만 selected 상태여야 한다. + +--- + +## 4. Non-Goals +- 기존 `TextTabBarView` 동작을 변경하지 않는다. +- 기존 화면에 신규 컴포넌트를 일괄 적용하지 않는다. +- Compose, 신규 Activity, Fragment, ViewModel을 추가하지 않는다. +- line 타입 또는 기존 text 타입 탭을 이번 컴포넌트에 통합하지 않는다. +- Figma에 없는 배지, 아이콘, 닫기 버튼, 스크롤 인디케이터를 추가하지 않는다. + +--- + +## 5. Target Users +- XML 레이아웃을 작성하거나 유지보수하는 Android 개발자. +- 메뉴 개수가 동적으로 늘어날 수 있는 화면에서 capsule 탭 필터를 재사용하려는 개발자. + +--- + +## 6. User Stories +- 개발자는 여러 개의 카테고리 메뉴를 capsule 형태로 표시하고 싶다. +- 개발자는 메뉴가 많을 때 화면 밖 메뉴를 가로 스크롤로 접근하게 하고 싶다. +- 개발자는 선택된 메뉴를 하나만 유지하고, 선택 변경 콜백으로 화면별 동작을 연결하고 싶다. + +--- + +## 7. Core Features + +### Capsule Tab Bar Component +Figma `20:3590` 기준 capsule 타입 탭바를 XML + Kotlin custom view로 제공한다. + +#### Requirements +- Figma: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3590&m=dev +- Container width: `match_parent` +- Container height: `52dp` +- Container background: black +- Container horizontal start padding: `spacing_20` +- Tab list layout: horizontal, center vertical +- Tab gap: `spacing_8` +- Tab height: `34dp` +- Tab horizontal padding: `spacing_12` +- Tab vertical padding: `spacing_8` +- Tab corner radius: capsule 형태. 현재 `radius_100` 토큰이 없으므로 구현 시 신규 dimen 토큰 추가 또는 drawable radius 직접 지정 중 하나를 계획에서 명시한다. +- Selected tab: background `soda_400`, text white, no border +- Normal tab: background black, border `gray_700`, text white +- 메뉴가 화면 너비를 넘으면 가로 스크롤되어야 한다. +- 메뉴 개수는 1개 이상을 허용한다. +- selected index가 범위를 벗어나면 첫 번째 메뉴를 selected로 보정한다. + +#### Edge Cases +- 메뉴가 0개이면 호출부 오류로 간주한다. +- 이미 선택된 탭을 다시 선택하면 중복 콜백을 호출하지 않는다. +- 메뉴 목록이 바뀌어 기존 selected index가 범위를 벗어나면 첫 번째 메뉴를 selected로 보정한다. + +--- + +## 8. UX / UI Expectations +- 첫 번째 탭은 왼쪽 20dp 지점에서 시작한다. +- 각 탭은 8dp 간격으로 배치된다. +- 탭은 텍스트 길이에 따라 width가 늘어나며, 고정 균등 분배하지 않는다. +- 메뉴가 많으면 자연스럽게 좌우 스크롤된다. +- 스크롤 영역은 탭바 높이 52dp 안에서 세로 중앙 정렬되어야 한다. + +--- + +## 9. Technical Constraints +- 현재 프로젝트는 XML Views + ViewBinding 기반이므로 XML 레이아웃과 Kotlin custom view 패턴을 사용한다. +- 신규 Kotlin 코드는 `kr.co.vividnext.sodalive.v2.widget` 패키지 하위에 둔다. +- 기존 `TextTabBarView`와 `TextTabSelectionState`의 동작을 깨지 않는다. +- 기존 디자인 토큰 `soda_400`, `gray_700`, `white`, `black`, `spacing_8`, `spacing_12`, `spacing_20`을 우선 사용한다. +- 필요한 경우 capsule radius 전용 dimen 또는 drawable 리소스를 최소 범위로 추가한다. + +--- + +## 10. Metrics +- 메뉴 1개 이상을 설정할 수 있고 항상 하나만 selected 상태다. +- 탭 항목이 화면 너비를 초과해도 가로 스크롤로 접근할 수 있다. +- Android 리소스 병합 및 디버그 빌드가 성공한다. +- 관련 선택 상태 테스트가 통과한다. + +--- + +## 11. Open Questions +- 사용자 응답이 없으므로 기존 `TextTabBarView` 확장이 아닌 별도 `CapsuleTabBarView` 추가를 기본 설계로 확정한다. diff --git a/docs/prd/20260519_타이틀바및탭텍스트바컴포넌트_prd.md b/docs/prd/20260519_타이틀바및탭텍스트바컴포넌트_prd.md new file mode 100644 index 00000000..cbcce0e2 --- /dev/null +++ b/docs/prd/20260519_타이틀바및탭텍스트바컴포넌트_prd.md @@ -0,0 +1,134 @@ +# PRD: 타이틀바 및 탭 텍스트바 컴포넌트 + +## 1. Overview +XML 레이아웃 기반 화면에서 재사용할 수 있는 Title Bar Component와 Tab-Text Bar Component를 개발한다. + +--- + +## 2. Problem +- 화면마다 타이틀 영역과 탭 텍스트 영역을 개별 구현하면 높이, 배경색, 정렬, 텍스트 스타일이 달라질 수 있다. +- Home 화면과 일반 화면의 타이틀 바 구조는 유사하지만 좌측 영역의 콘텐츠가 다르므로 재사용 가능한 변형이 필요하다. +- 탭 텍스트 바는 최대 3개 메뉴와 단일 선택 상태를 일관되게 보장해야 한다. + +--- + +## 3. Goals +- 전체 너비, 높이 60dp, 검정 배경, 세로 가운데 정렬을 갖는 재사용 가능한 Title Bar Component를 제공한다. +- Home Title Bar는 좌측에 `img_text_logo_v2`, 우측에 화면별로 교체 가능한 메뉴 아이콘을 배치할 수 있어야 한다. +- Default Title Bar는 좌측에 화면명 텍스트, 우측에 화면별로 교체 가능한 메뉴 아이콘을 배치할 수 있어야 한다. +- 전체 너비, 높이 52dp, 검정 배경, 세로 가운데 정렬을 갖는 재사용 가능한 Tab-Text Bar Component를 제공한다. +- Tab-Text Bar는 최대 3개 메뉴를 지원하고 반드시 하나만 selected 상태가 되도록 설계한다. +- Tab-Text Bar 텍스트는 `Typography.Heading3`을 사용하고 normal 색상은 `gray_600`, selected 색상은 white를 사용한다. + +--- + +## 4. Non-Goals +- 이번 범위에서는 실제 화면에 컴포넌트를 일괄 적용하지 않는다. +- 신규 Activity, Fragment, ViewModel을 만들지 않는다. +- Compose 컴포넌트 또는 Compose Theme를 추가하지 않는다. +- 3개를 초과하는 탭 메뉴 지원, 스크롤 탭, 아이콘 탭, 배지 기능은 포함하지 않는다. +- 메뉴 아이콘 클릭 시 수행할 화면별 비즈니스 로직은 컴포넌트 내부에 고정하지 않는다. +- Figma에서 제공되지 않은 추가 애니메이션, 그림자, divider, pressed 효과는 추가하지 않는다. + +--- + +## 5. Target Users +- XML 레이아웃을 작성하거나 유지보수하는 Android 개발자. +- 신규 v2 화면에서 공통 상단 바와 텍스트 탭 바를 재사용하려는 개발자. + +--- + +## 6. User Stories +- 개발자는 Home 화면에서 로고와 메뉴 아이콘이 포함된 상단 바를 반복 구현 없이 재사용하고 싶다. +- 개발자는 일반 화면에서 화면명과 메뉴 아이콘이 포함된 상단 바를 같은 규격으로 사용하고 싶다. +- 개발자는 화면별 요구에 맞게 우측 메뉴 아이콘을 교체하고 클릭 동작을 주입하고 싶다. +- 개발자는 최대 3개 메뉴 중 하나만 선택되는 텍스트 탭 바를 재사용하고 싶다. + +--- + +## 7. Core Features + +### Title Bar Component +재사용 가능한 상단 타이틀 바를 XML 기반으로 제공한다. + +#### Requirements +- Width: `match_parent` +- Height: `60dp` +- Alignment: 세로 가운데 정렬 +- Background: black +- 공통 구조는 `좌측 콘텐츠 영역` + `빈 영역` + `우측 메뉴 아이콘 영역`으로 구성한다. +- 우측 메뉴 아이콘은 사용하는 화면마다 다른 drawable을 지정할 수 있어야 한다. +- 우측 메뉴 아이콘은 없는 화면에서도 사용할 수 있도록 숨김 처리가 가능해야 한다. + +#### Home Title Bar +- Figma: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3575&m=dev +- 좌측 콘텐츠는 `img_text_logo_v2`를 표시한다. +- 중앙 빈 영역은 가변 폭으로 남은 공간을 채운다. +- 우측 메뉴 아이콘은 화면에서 교체 가능해야 한다. + +#### Default Title Bar +- Figma: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3576&m=dev +- 좌측 콘텐츠는 화면명 텍스트를 표시한다. +- 화면명 텍스트는 사용하는 화면마다 변경 가능해야 한다. +- 중앙 빈 영역은 가변 폭으로 남은 공간을 채운다. +- 우측 메뉴 아이콘은 화면에서 교체 가능해야 한다. + +#### Edge Cases +- 메뉴 아이콘이 없으면 우측 아이콘 영역을 숨기거나 클릭 불가능한 상태로 둔다. +- Default Title Bar의 화면명이 비어 있으면 빈 텍스트 그대로 표시하지 않고 호출부에서 유효한 화면명을 제공해야 한다. +- `img_text_logo_v2` 리소스가 없는 경우 구현 단계에서 기존 로고 리소스 존재 여부를 먼저 확인하고, 없으면 해당 리소스를 추가 대상으로 명시한다. + +### Tab-Text Bar Component +최대 3개 텍스트 메뉴를 보여주는 재사용 가능한 탭 바를 XML 기반으로 제공한다. + +#### Requirements +- Figma: https://www.figma.com/design/HmN1yNdJ3EIpqknFL0Hkab/-%EA%B3%B5%EC%9C%A0%EC%9A%A9-%EB%B3%B4%EC%9D%B4%EC%8A%A4%EC%98%A8-UI-UX-%EA%B8%B0%ED%9A%8D%EB%AC%B8%EC%84%9C?node-id=20-3585&m=dev +- Width: `match_parent` +- Height: `52dp` +- Alignment: 세로 가운데 정렬 +- Background: black +- Typography: `Typography.Heading3` +- Normal Color: `gray_600` +- Selected Color: white +- 메뉴 개수는 1개 이상 3개 이하만 허용한다. +- 메뉴 선택 시 선택된 메뉴만 selected 상태가 되고 나머지는 normal 상태가 되어야 한다. +- 초기 상태에서도 반드시 하나의 메뉴가 selected 상태여야 한다. + +#### Edge Cases +- 메뉴가 0개이면 호출부 오류로 간주하고 컴포넌트 사용을 허용하지 않는다. +- 메뉴가 4개 이상이면 호출부 오류로 간주하고 컴포넌트 사용을 허용하지 않는다. +- selected index가 메뉴 범위를 벗어나면 첫 번째 메뉴를 selected로 보정하는 단순한 동작을 기본값으로 한다. + +--- + +## 8. UX / UI Expectations +- Title Bar와 Tab-Text Bar는 모두 검정 배경 위에서 세로 중앙 정렬되어야 한다. +- Home Title Bar는 `img_text_logo_v2`와 우측 메뉴 아이콘 사이에 빈 영역이 자연스럽게 확장되어야 한다. +- Default Title Bar는 화면명과 우측 메뉴 아이콘 사이에 빈 영역이 자연스럽게 확장되어야 한다. +- Tab-Text Bar는 selected 텍스트가 white, normal 텍스트가 `gray_600`으로 명확히 구분되어야 한다. +- 터치 가능한 메뉴 아이콘과 탭 텍스트는 화면별 클릭 핸들러를 연결할 수 있어야 한다. + +--- + +## 9. Technical Constraints +- 현재 프로젝트는 XML Views + ViewBinding 기반이므로 XML 레이아웃과 Kotlin View/ViewBinding 사용 패턴을 우선한다. +- 재사용 레이아웃은 `app/src/main/res/layout` 아래에 둔다. +- 기존 유사 패턴은 `toolbar_audio_content_main.xml`, `activity_audio_content_main.xml`, `activity_can_status.xml`, `fragment_message.xml`을 참고한다. +- 색상은 기존 `colors.xml`의 black, white, `gray_600` 토큰을 사용한다. +- Typography는 기존 `app/src/main/res/values/typography.xml`의 `Typography.Heading3`을 사용한다. +- 기존 화면의 동작이나 레이아웃을 요청 없이 변경하지 않는다. + +--- + +## 10. Metrics +- Title Bar 높이 60dp와 Tab-Text Bar 높이 52dp가 문서와 구현에서 일치한다. +- Home Title Bar와 Default Title Bar가 각각 요구된 좌측 콘텐츠를 지원한다. +- 메뉴 아이콘과 화면명은 호출 화면에서 변경 가능하다. +- Tab-Text Bar는 1~3개 메뉴만 허용하고 항상 하나의 selected 상태를 유지한다. +- Android 리소스 병합 및 디버그 빌드가 성공한다. + +--- + +## 11. Open Questions +- Figma 조회는 현재 도구 타임아웃으로 확인하지 못했으므로, 구현 단계에서는 제공된 텍스트 요구사항을 우선 기준으로 삼는다. +- `img_text_logo_v2`가 이미 drawable 리소스로 존재하는지 구현 전 확인이 필요하다.