Files
sodalive-android/docs/plan-task/20260527_배너컴포넌트.md

545 lines
49 KiB
Markdown

# 배너 컴포넌트 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 `24:5525`와 PRD `docs/prd/20260527_배너컴포넌트_prd.md` 기준으로 자동 전환, 무한 순환, 수동 스와이프, 카운터 오버레이, XML 미리보기를 제공하는 재사용 가능한 Android XML Views 배너 컴포넌트를 추가한다.
**Architecture:** 기존 `BannerViewPager` 래핑 없이 `kr.co.vividnext.sodalive.v2.widget.banner` 하위에 순수 Kotlin 상태/계산 contract와 Android custom view를 분리한다. 배너 UI는 `RecyclerView + PagerSnapHelper` 기반으로 구현하고, 이미지 로딩과 터치 후 이동은 컴포넌트 내부에서 결정하지 않고 호출부 callback 또는 노출된 `ImageView` 바인딩으로 위임한다.
**Tech Stack:** Android XML Views, Kotlin custom View, RecyclerView, PagerSnapHelper, Handler/Runnable 또는 lifecycle-aware timer, ViewBinding/resource merge, JUnit4 local unit test, Robolectric view test.
---
## 작업 목표
- 배너 item은 `1:1` 비율이며 width/height는 `screenWidth - 40dp`로 계산한다.
- `BannerView`는 XML에서 `layout_width="match_parent"`, `layout_height="wrap_content"`로 배치해도 화면 폭에 맞는 높이를 자체 계산한다.
- 현재 배너는 가운데 정렬하고 화면 좌우 `20dp` 기준 여백을 유지한다.
- 배너가 2개 이상이면 item 간격 `8dp`와 좌우 이전/다음 배너 일부 노출을 제공한다.
- 배너 목록 0개는 숨김, 1개는 단일 이미지, 2개 이상은 카운터/스와이프/자동 전환을 적용한다.
- 자동 전환 주기는 5초, 전환 애니메이션 시간은 350ms다.
- 마지막 배너 다음은 첫 번째 배너, 첫 번째 배너 이전은 마지막 배너로 이어지는 무한 순환을 제공한다.
- 사용자가 직접 스와이프하면 자동 전환 타이머를 초기화한다.
- 터치 액션은 호출부 콜백으로 위임한다.
- XML layout editor에서 샘플 이미지/placeholder와 샘플 카운터를 미리 볼 수 있어야 한다.
## 파일 구조
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerItem.kt`
- 배너 컴포넌트 전용 UI 모델을 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculator.kt`
- 화면 폭, 좌우 여백 `20dp`, item 간격 `8dp` 기준으로 item size와 peek/padding 값을 계산한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatter.kt`
- `01 / 20` 형식의 현재 index/전체 count 문자열을 만든다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerState.kt`
- item count별 표시 모드, 현재 index 보정, 다음/이전 index 계산을 담당한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt`
- RecyclerView item을 생성하고 이미지 view와 클릭 callback을 연결한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
- XML custom view 루트로 RecyclerView, counter, timer, lifecycle start/stop을 조합한다.
- Create: `app/src/main/res/layout/view_banner.xml`
- `BannerView` 루트, 내부 RecyclerView, 우상단 counter overlay를 정의한다.
- Create: `app/src/main/res/layout/item_banner.xml`
- `1:1` 이미지 item layout을 정의한다.
- Create: `app/src/main/res/drawable/bg_banner_counter.xml`
- 반투명 검정 capsule counter 배경을 정의한다.
- Create if needed: `app/src/main/res/drawable/bg_banner_preview_placeholder.xml`
- XML 미리보기용 placeholder 배경을 정의한다. 기존 적절한 placeholder drawable이 있으면 새 파일 대신 재사용한다.
- Modify: `app/src/main/res/values/attrs.xml`
- 없으면 생성하고, XML preview용 `bannerPreviewItemCount`, `bannerPreviewCurrentIndex`, `bannerPreviewImage` 속성을 정의한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculatorTest.kt`
- `screenWidth - 40dp`, `1:1`, `8dp` 간격 계산을 검증한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatterTest.kt`
- 두 자리 counter 형식을 검증한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerStateTest.kt`
- 0/1/2개 이상 표시 모드와 무한 순환 index 계산을 검증한다.
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- Robolectric으로 visibility, counter, adapter click, preview 속성 반영을 검증한다.
- Modify: `docs/agent-guides/build-test-style.md`
- 신규 배너 테스트 단일 실행 예시를 추가한다.
- Modify: `docs/plan-task/20260527_배너컴포넌트.md`
- 구현 중 체크박스와 검증 기록을 누적한다.
## 구현 계획
### Phase 1: 기준 확인 및 구현 경계 확정
- [x] **Task 1.1: PRD와 Figma 기준 재확인**
- 확인 파일: `docs/prd/20260527_배너컴포넌트_prd.md`
- Figma 기준: `24:5525`
- 확인 항목: `1:1` item 비율, `screenWidth - 40dp`, 좌우 `20dp`, item 간격 `8dp`, 좌우 peek, radius `14dp`, counter 위치 `top/right 14dp`, counter text `01 / 20`.
- 검증: PRD의 Goals, Visual Requirements, Behavior Requirements, Metrics가 계획의 phase/task에 모두 매핑되는지 확인한다.
- [x] **Task 1.2: 기존 v2 custom view와 테스트 패턴 확인**
- 확인 파일:
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/AudioContentCardView.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedAdapter.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/feed/FeedViewTest.kt`
- `app/src/main/res/values/colors.xml`
- `app/src/main/res/values/dimens.xml`
- `app/src/main/res/values/typography.xml`
- 검증: `@JvmOverloads` custom view, `ImageView` 노출, Robolectric inflate test, 기존 token 이름을 확인한다.
- [x] **Task 1.3: RecyclerView 기반 배너 구현 경계 확정**
- 구현 원칙: 기존 `BannerViewPager`는 래핑하거나 확장하지 않는다.
- 구현 방식: `RecyclerView` horizontal layout + `PagerSnapHelper` + adapter virtual position 또는 index remap으로 무한 순환을 구현한다.
- 검증: `app/build.gradle``androidx.recyclerview:recyclerview` 의존성이 있으므로 신규 pager 의존성을 추가하지 않는다.
### Phase 2: 순수 Kotlin contract와 단위 테스트
- [x] **Task 2.1: `BannerItem` 모델 추가**
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerItem.kt`
- 요구사항: `bannerId: String?`, `imageUrl: String`만 포함한다.
- 검증: 서버 DTO, 딥링크 타입, 외부 URL 타입을 모델에 포함하지 않는다.
- [x] **Task 2.2: `BannerLayoutCalculatorTest` 작성**
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculatorTest.kt`
- 테스트 항목:
- 화면 폭 `402dp`이면 item width/height는 `362dp`.
- 화면 폭 `360dp`이면 item width/height는 `320dp`.
- item spacing은 항상 `8dp`.
- horizontal side inset 기준값은 `20dp`.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerLayoutCalculatorTest"`
- 기대: 구현 전 실패, 구현 후 성공.
- [x] **Task 2.3: `BannerLayoutCalculator` 구현**
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerLayoutCalculator.kt`
- 요구사항:
- `screenWidthPx`, `density` 또는 dp 변환 가능한 입력을 받아 item width/height를 계산한다.
- side inset은 `20dp`, item spacing은 `8dp`로 고정한다.
- item height는 item width와 동일하다.
- 검증: `BannerLayoutCalculatorTest`가 통과한다.
- [x] **Task 2.4: `BannerCounterFormatterTest` 작성**
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatterTest.kt`
- 테스트 항목:
- current index `0`, count `20`이면 `01 / 20`.
- current index `9`, count `20`이면 `10 / 20`.
- count `1`이어도 formatter 자체는 `01 / 01`을 만들 수 있지만 view에서는 1개일 때 counter를 숨긴다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerCounterFormatterTest"`
- 기대: 구현 전 실패, 구현 후 성공.
- [x] **Task 2.5: `BannerCounterFormatter` 구현**
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerCounterFormatter.kt`
- 요구사항: 현재 index는 0-based 입력을 받고 표시값은 1-based 두 자리 문자열로 변환한다.
- 검증: `BannerCounterFormatterTest`가 통과한다.
- [x] **Task 2.6: `BannerStateTest` 작성**
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerStateTest.kt`
- 테스트 항목:
- count 0은 `Hidden` 모드다.
- count 1은 `Single` 모드이며 swipe/auto/counter 대상이 아니다.
- count 2 이상은 `Carousel` 모드다.
- 마지막 index의 next는 0이다.
- 첫 번째 index의 previous는 마지막 index다.
- 목록 갱신으로 current index가 범위를 벗어나면 0으로 보정한다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerStateTest"`
- 기대: 구현 전 실패, 구현 후 성공.
- [x] **Task 2.7: `BannerState` 구현**
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerState.kt`
- 요구사항: 표시 모드, 현재 index 보정, next/previous 계산을 Android view 의존성 없이 구현한다.
- 검증: `BannerStateTest`가 통과한다.
### Phase 3: XML 리소스와 디자인 타임 미리보기
- [x] **Task 3.1: counter 배경 drawable 추가**
- 생성 파일: `app/src/main/res/drawable/bg_banner_counter.xml`
- 요구사항:
- solid color는 `#B3000000` 또는 동등한 `rgba(0,0,0,0.7)` 표현을 사용한다.
- corners radius는 capsule 형태로 충분히 큰 값을 사용한다.
- 검증: resource merge 시 drawable이 정상 참조된다.
- [x] **Task 3.2: preview placeholder drawable 추가 또는 재사용 결정**
- 생성 후보: `app/src/main/res/drawable/bg_banner_preview_placeholder.xml`
- 요구사항:
- XML layout editor에서 이미지 데이터 없이도 배너 영역이 보인다.
- 기존 적절한 placeholder drawable이 있으면 새 drawable을 만들지 않고 해당 리소스를 사용한다.
- 검증: `item_banner.xml``tools:src` 또는 `android:background`에서 참조 가능하다.
- [x] **Task 3.3: preview attrs 추가**
- 생성 또는 수정 파일: `app/src/main/res/values/attrs.xml`
- 추가 속성:
- `bannerPreviewItemCount` format `integer`
- `bannerPreviewCurrentIndex` format `integer`
- `bannerPreviewImage` format `reference`
- 검증: `BannerView`에서 `context.obtainStyledAttributes(attrs, R.styleable.BannerView)`로 읽을 수 있다.
- [x] **Task 3.4: `item_banner.xml` 추가**
- 생성 파일: `app/src/main/res/layout/item_banner.xml`
- 요구사항:
- root는 `FrameLayout`.
- 내부 `ImageView``match_parent`, `centerCrop`, `contentDescription="@null"`.
- root와 image는 `14dp` radius clip 대상이 될 수 있어야 한다.
- 검증: XML preview에서 정사각형 item 내부 이미지 영역이 확인된다.
- [x] **Task 3.5: `view_banner.xml` 추가**
- 생성 파일: `app/src/main/res/layout/view_banner.xml`
- 요구사항:
- root는 `kr.co.vividnext.sodalive.v2.widget.banner.BannerView`.
- 내부 `RecyclerView`는 horizontal carousel 용도로 배치하고 `clipToPadding=false`, `clipChildren=false`를 적용한다.
- counter container는 우상단 `14dp` margin, `bg_banner_counter`, padding horizontal `6dp`, vertical `4dp`.
- counter는 현재 index, `/`, total count를 분리된 TextView 또는 span으로 표현해 현재 index white, 나머지 `gray_400`을 적용한다.
- `tools:` 속성으로 샘플 counter와 preview image가 보이게 한다.
- 검증: XML preview에서 배너 형태, radius, counter 위치가 확인된다.
### Phase 4: Adapter와 View 구현
- [x] **Task 4.1: `BannerAdapter` 구현**
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt`
- 요구사항:
- `RecyclerView.Adapter`로 구현한다.
- item count 2개 이상에서는 virtual position을 실제 item index로 remap해 무한 순환에 필요한 충분한 scroll range를 제공한다.
- item count 0/1개에서는 실제 count만 반환한다.
- `ImageView`는 호출부가 이미지 로딩을 연결할 수 있도록 bind callback으로 전달한다.
- item click은 현재 `BannerItem`을 호출부 callback으로 전달한다.
- 검증: adapter 단위 또는 view test에서 click callback이 현재 item을 전달한다.
- [x] **Task 4.2: `BannerView` 기본 구조 구현**
- 생성 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
- 요구사항:
- `@JvmOverloads constructor(context, attrs, defStyleAttr)` 패턴을 사용한다.
- `view_banner.xml`을 inflate하고 내부 RecyclerView/counter view를 찾는다.
- `setItems(items: List<BannerItem>)` API를 제공한다.
- `setOnBannerClickListener(listener: ((BannerItem) -> Unit)?)` API를 제공한다.
- `setOnBindBannerImage(listener: ((ImageView, BannerItem) -> Unit)?)` 또는 동등한 이미지 바인딩 API를 제공한다.
- 검증: 빈 목록, 단일 목록, 복수 목록을 설정해 visibility와 counter 상태가 바뀐다.
- [x] **Task 4.3: layout size와 peek 적용**
- 수정 파일: `BannerView.kt`, `BannerAdapter.kt`
- 요구사항:
- view width를 기준으로 item size를 `width - 40dp`로 계산한다.
- `layout_height="wrap_content"`이면 view 자체 measured height도 `width - 40dp` 기준으로 결정한다.
- item layout params는 width/height 동일 값으로 설정한다.
- RecyclerView horizontal padding 또는 item decoration을 조합해 현재 item 가운데 정렬, item 간격 `8dp`, 좌우 peek 노출을 적용한다.
- 단일 item은 peek 없이 가운데 정렬한다.
- 검증: `BannerLayoutCalculatorTest``BannerViewTest`에서 item size, spacing, counter visibility를 확인한다.
- [x] **Task 4.4: counter 표시 구현**
- 수정 파일: `BannerView.kt`
- 요구사항:
- 0개/1개에서는 counter를 숨긴다.
- 2개 이상에서는 `BannerCounterFormatter` 결과를 표시한다.
- 현재 index는 white, slash와 total count는 `gray_400`을 적용한다.
- scroll settle 후 실제 item index에 맞춰 counter를 갱신한다.
- 검증: 빠른 scroll 후에도 counter index가 현재 item과 일치한다.
- [x] **Task 4.5: radius clipping 구현**
- 수정 파일: `BannerView.kt` 또는 item view holder
- 요구사항:
- 배너 image/root에 `radius_14` outline provider를 적용한다.
- Android XML preview와 runtime 모두에서 corner radius가 적용된다.
- 검증: Robolectric에서는 outline provider 설정 여부를 확인하고, 수동 확인에서는 radius clipping을 확인한다.
### Phase 5: 자동 전환, 수동 스와이프, lifecycle
- [x] **Task 5.1: 자동 전환 timer 구현**
- 수정 파일: `BannerView.kt`
- 요구사항:
- 배너 2개 이상일 때만 5초 timer를 시작한다.
- timer tick 시 다음 배너 방향으로 이동한다.
- 전환 애니메이션 시간은 350ms를 기준으로 한다.
- 검증: 테스트 가능한 timer wrapper를 두거나 Robolectric scheduler로 자동 전환 호출을 검증한다.
- [x] **Task 5.2: 무한 순환 이동 구현**
- 수정 파일: `BannerView.kt`, `BannerAdapter.kt`, `BannerState.kt`
- 요구사항:
- 마지막 배너 다음 방향은 첫 번째 배너로 이어진다.
- 첫 번째 배너 이전 방향은 마지막 배너로 이어진다.
- virtual position을 사용하는 경우 실제 index와 counter index가 항상 일치한다.
- 검증: `BannerStateTest``BannerViewTest`에서 next/previous remap을 확인한다.
- [x] **Task 5.3: 수동 스와이프 timer reset 구현**
- 수정 파일: `BannerView.kt`
- 요구사항:
- RecyclerView scroll state가 사용자 drag로 시작되면 자동 전환 예약을 취소한다.
- scroll settle 후 현재 시점부터 5초 timer를 다시 예약한다.
- 검증: 수동 scroll 이벤트 시 timer reset 메서드가 호출되는지 view test에서 확인한다.
- [x] **Task 5.4: attach/detach lifecycle 정리**
- 수정 파일: `BannerView.kt`
- 요구사항:
- `onAttachedToWindow()`에서 조건에 맞으면 timer를 시작한다.
- `onDetachedFromWindow()`에서 timer callback을 제거한다.
- item count가 0/1개로 바뀌면 timer를 중지한다.
- 검증: detach 후 pending callback이 남지 않도록 테스트하거나 코드 리뷰 체크리스트에 명시한다.
### Phase 6: 상태별 view test와 회귀 방지
- [x] **Task 6.1: `BannerViewTest` 기본 상태 테스트 작성**
- 생성 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 테스트 항목:
- empty items 설정 시 root visibility는 `GONE`.
- one item 설정 시 root visibility는 `VISIBLE`, counter는 `GONE`.
- two items 설정 시 counter는 `VISIBLE`.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- [x] **Task 6.2: `BannerViewTest` preview 속성 테스트 작성**
- 수정 파일: `BannerViewTest.kt`
- 테스트 항목:
- edit mode 또는 preview 속성 처리 경로에서 sample count/current index가 counter에 반영된다.
- preview image 속성이 있으면 placeholder/image view에 적용된다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- [x] **Task 6.3: `BannerViewTest` click callback 테스트 작성**
- 수정 파일: `BannerViewTest.kt`
- 테스트 항목:
- 첫 번째 배너 클릭 시 첫 번째 `BannerItem`이 callback으로 전달된다.
- click listener가 null이어도 클릭으로 crash가 발생하지 않는다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- [x] **Task 6.4: `BannerViewTest` layout 계산 연결 테스트 작성**
- 수정 파일: `BannerViewTest.kt`
- 테스트 항목:
- 측정된 width 기준 item layout params가 정사각형으로 설정된다.
- `width=match_parent`, `height=wrap_content` 조합으로 측정하면 root measured height가 `width - 40dp`와 일치한다.
- 복수 item일 때 item decoration 또는 padding이 `8dp` spacing과 좌우 peek 조건을 반영한다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- [x] **Task 6.5: `BannerView` wrap_content 높이 측정 보완**
- 수정 파일: `BannerView.kt`, `BannerViewTest.kt`
- 요구사항:
- 호출부 XML에서 `layout_width="match_parent"`, `layout_height="wrap_content"`를 사용해도 화면 폭별로 배너 높이가 자동 계산된다.
- root measured height는 measured width 기준 `width - 40dp`와 일치한다.
- 고정 `362dp` 같은 화면별 상수 height를 호출부에 요구하지 않는다.
- 기존 item size, 좌우 `20dp` inset, `8dp` spacing, counter 위치 동작은 유지한다.
- 테스트 항목:
- `BannerViewTest`에서 width `402dp`, height `wrap_content` 측정 시 measured height가 `362dp`인지 확인한다.
- width `360dp`, height `wrap_content` 측정 시 measured height가 `320dp`인지 확인한다.
- 명시 height가 들어온 경우 기존 부모 측정 제약을 깨지 않는지 확인한다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
### Phase 7: 문서, 검증, 마무리
- [x] **Task 7.1: 테스트 실행 예시 문서 갱신**
- 수정 파일: `docs/agent-guides/build-test-style.md`
- 추가 내용: 배너 컴포넌트 단일 테스트 실행 예시.
- 예시 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`
- 검증: 기존 문서 형식과 중복되지 않게 최소 변경한다.
- [x] **Task 7.2: 단위 테스트 실행**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerLayoutCalculatorTest"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerCounterFormatterTest"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerStateTest"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대 결과: 모두 `BUILD SUCCESSFUL`.
- [x] **Task 7.3: 리소스/빌드 검증**
- 실행:
- `./gradlew :app:assembleDebug`
- 필요 시 `./gradlew :app:ktlintCheck`
- 기대 결과: resource merge, Kotlin compile, ktlint가 성공한다.
- [x] **Task 7.4: 수동 확인 항목 기록**
- 확인 항목:
- XML layout editor에서 배너 preview, radius, counter 위치가 보인다.
- 1개 배너는 정사각형 단일 이미지로 가운데 정렬된다.
- 2개 이상 배너는 좌우 이전/다음 배너 일부가 보인다.
- 자동 전환은 5초 주기로 동작한다.
- 수동 스와이프 후 자동 전환 timer가 다시 5초부터 시작한다.
- 마지막/첫 번째 경계에서 무한 순환이 자연스럽다.
- 기록 위치: 이 문서 하단 `검증 기록` 섹션.
### Phase 8: PRD 불일치 및 검증 갭 보완
- [x] **Task 8.1: counter 시각 형식 회귀 테스트 작성**
- 수정 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 대상 리스크: counter가 PRD의 `01 / 20` 형식, `14sp`, line-height `1.45` 기준과 다르게 표시될 수 있다.
- 테스트 항목:
- 복수 배너 counter가 실제 조합 기준으로 `01 / 02`처럼 slash 앞뒤 공백을 포함해 보이는지 검증한다.
- 현재 index는 `white`, separator/total count는 `gray_400`을 유지하는지 검증한다.
- counter text style이 PRD 기준 `14sp` token 또는 동등한 typography로 적용되는지 검증한다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대: 구현 전에는 counter 공백 또는 typography mismatch로 실패하고, 구현 후 성공한다.
- [x] **Task 8.2: counter 시각 형식 구현 보완**
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`, `app/src/main/res/layout/view_banner.xml`
- 요구사항:
- counter가 실제 렌더링에서 `01 / 20` 형태로 표시되도록 separator 주변 공백 또는 동등한 간격을 명시한다.
- 현재 index는 `white`, separator와 total count는 `gray_400`을 유지한다.
- PRD 기준 `14sp`, line-height `1.45`에 맞는 기존 typography token을 우선 사용한다.
- 검증: Task 8.1 테스트가 통과한다.
- [x] **Task 8.3: lifecycle stop timer 중지 테스트 작성**
- 수정 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 대상 리스크: view가 detach되지 않고 lifecycle만 stop되는 경우 자동 전환 timer가 계속 동작할 수 있다.
- 테스트 항목:
- `BannerView`가 attached 상태에서 lifecycle `ON_STOP`을 받으면 pending auto scroll callback이 제거된다.
- lifecycle `ON_START` 또는 재개 조건에서 배너가 2개 이상이면 auto scroll timer가 다시 예약된다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대: 구현 전에는 lifecycle stop 시나리오가 실패하고, 구현 후 성공한다.
- [x] **Task 8.4: lifecycle stop timer 중지 구현**
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
- 요구사항:
- `ViewTreeLifecycleOwner` 또는 기존 프로젝트 관례에 맞는 lifecycle 관찰 방식으로 stop 시 timer를 중지한다.
- detach cleanup은 기존대로 유지한다.
- lifecycle owner가 없는 preview/test 경로에서 crash가 발생하지 않아야 한다.
- 검증: Task 8.3 테스트와 기존 attach/detach cleanup 테스트가 모두 통과한다.
- [x] **Task 8.5: 목록 갱신 시 첫 번째 배너 재시작 테스트 작성**
- 수정 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 대상 리스크: `setItems()`가 기존 `currentIndex`를 유지해 PRD의 "목록 갱신 후 2개 이상이면 첫 번째 배너 기준으로 다시 시작" 요구와 다를 수 있다.
- 테스트 항목:
- 현재 counter가 두 번째 이상인 상태에서 새 복수 배너 목록을 `setItems()`로 설정하면 counter가 `01`로 초기화된다.
- 목록 갱신 후 auto scroll timer도 첫 번째 배너 기준으로 다시 시작된다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대: 구현 전에는 기존 index 유지로 실패하고, 구현 후 성공한다.
- [x] **Task 8.6: 목록 갱신 시 첫 번째 배너 재시작 구현**
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
- 요구사항:
- 새 목록이 2개 이상이면 `currentIndex`와 adapter position을 첫 번째 배너 기준으로 초기화한다.
- 새 목록이 0개이면 숨김과 timer 중지를 유지한다.
- 새 목록이 1개이면 첫 번째 이미지만 표시하고 counter/auto scroll을 비활성화한다.
- 검증: Task 8.5 테스트와 기존 0/1/2개 상태 테스트가 모두 통과한다.
- [x] **Task 8.7: 수동 경계 스와이프 및 빠른 연속 스와이프 View 테스트 보강**
- 수정 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 대상 리스크: 순수 index 계산은 검증됐지만 실제 `RecyclerView` snap/counter가 수동 경계 스와이프와 빠른 연속 스와이프에서 일치하는지 직접 검증하지 않는다.
- 테스트 항목:
- 첫 번째 배너에서 이전 방향으로 이동한 뒤 idle 상태가 되면 마지막 배너 counter가 표시된다.
- 마지막 배너에서 다음 방향으로 이동한 뒤 idle 상태가 되면 첫 번째 배너 counter가 표시된다.
- 빠른 연속 position 변경 후 idle 상태가 되면 snap된 adapter position의 실제 index와 counter가 일치한다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대: 구현이 이미 충족하면 GREEN을 확인하고, 실패하면 Task 8.8에서 최소 수정한다.
- [x] **Task 8.8: 수동 스와이프 counter 동기화 보완**
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`, 필요 시 `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerAdapter.kt`
- 요구사항:
- `SCROLL_STATE_IDLE` 시 snap된 adapter position을 기준으로 실제 item index와 counter를 항상 동기화한다.
- 첫 번째/마지막 경계에서 수동 이전/다음 이동이 끊김 없이 순환한다.
- 빠른 연속 스와이프 후에도 counter가 실제 표시 item과 일치한다.
- 검증: Task 8.7 테스트와 기존 자동 전환, drag reset, 무한 순환 테스트가 모두 통과한다.
- [x] **Task 8.9: Phase 8 최종 검증 실행 및 기록**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`
- `./gradlew :app:assembleDebug`
- `./gradlew :app:ktlintCheck`
- 기록 위치: 이 문서 하단 `검증 기록` 섹션.
- 기대 결과: 모두 `BUILD SUCCESSFUL`이며, 실제 기기 또는 Android Studio XML layout editor 확인이 가능하면 counter 공백/typography, preview, peek, radius를 수동 확인한다.
### Phase 9: 실제 기기 시각 보정
- [x] **Task 9.1: carousel item 중심 쏠림 회귀 테스트 작성**
- 수정 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 대상 리스크: item 간격을 오른쪽 offset으로만 적용하면 `PagerSnapHelper`의 decorated center 기준과 실제 이미지 중심이 달라져 현재 banner가 왼쪽으로 치우쳐 보일 수 있다.
- 테스트 항목:
- 복수 banner에서 item 사이 실제 간격은 `8dp`를 유지한다.
- spacing decoration은 좌우 대칭이거나, snap 기준 중심과 item image 중심이 일치하는 값을 반환한다.
- 단일 banner에서는 기존처럼 spacing을 적용하지 않는다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대: 현재 구현이 오른쪽 offset만 사용하면 RED가 되고, 대칭 offset 또는 동등한 중심 보정 구현 후 GREEN이 된다.
- [x] **Task 9.2: carousel item 중심 쏠림 보정 구현**
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
- 요구사항:
- 복수 banner item spacing `8dp`는 유지한다.
- `BannerSpacingDecoration`은 item 오른쪽에만 spacing을 몰아주지 않고 좌우 `4dp`씩 나누거나, `PagerSnapHelper` 기준 중심과 실제 이미지 중심이 일치하는 방식으로 구현한다.
- 단일 item은 spacing `0`을 유지한다.
- 검증: Task 9.1 테스트와 기존 layout size, wrap content, counter, auto scroll 테스트가 모두 통과한다.
- [x] **Task 9.3: counter item 기준 위치 회귀 테스트 작성**
- 수정 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 대상 리스크: counter가 `BannerView` root 우상단 `14dp`에 붙으면 Figma의 "중앙 banner item 내부 우상단 `14dp`" 위치보다 오른쪽으로 치우친다.
- 테스트 항목:
- width `402dp`, side inset `20dp` 기준 counter root end margin은 `34dp`다.
- top margin은 `14dp`를 유지한다.
- width가 바뀌어도 counter는 현재 banner item의 우측 경계에서 `14dp` 안쪽에 위치한다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대: 현재 구현이 root end `14dp`이면 RED가 되고, item 기준 end `34dp` 적용 후 GREEN이 된다.
- [x] **Task 9.4: counter item 기준 위치 보정 구현**
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`, 필요 시 `app/src/main/res/layout/view_banner.xml`
- 요구사항:
- counter top margin은 Figma 기준 `14dp`를 유지한다.
- counter end margin은 `sideInsetDp + 14dp`로 계산해 현재 banner item 내부 우상단 기준에 맞춘다.
- `view_banner.xml`에는 기본값을 유지하되, runtime layout size 적용 시 동적으로 보정한다.
- 검증: Task 9.3 테스트와 기존 counter 형식/색상/typography 테스트가 모두 통과한다.
- [x] **Task 9.5: 실제 기기 수동 확인 기록**
- 확인 항목:
- 현재 banner image가 화면 중앙에 정렬되어 보인다.
- 좌우 이전/다음 banner 일부가 대칭적으로 노출된다.
- counter가 중앙 banner item 내부 우상단 `14dp` 위치에 보인다.
- 기록 위치: 이 문서 하단 `검증 기록` 섹션.
### Phase 10: 초기 표시 상태 중심 정렬 보완
- [x] **Task 10.1: 최초 렌더링 중심 정렬 회귀 테스트 작성**
- 수정 파일: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerViewTest.kt`
- 대상 리스크: Phase 9 보정 후 자동 또는 수동 슬라이드가 1회 이상 발생하면 중심이 맞지만, 최초 표시 상태에서는 초기 adapter position 적용 시점과 item size/padding/decoration 적용 시점 차이로 현재 banner가 약간 우측으로 치우쳐 보일 수 있다.
- 테스트 항목:
- 복수 banner를 `setItems()`로 설정하고 최초 `measure/layout`이 끝난 직후, 별도 scroll 이벤트 없이 현재 banner item 중심이 `BannerView` 중심과 일치한다.
- 최초 표시 상태의 좌우 peek 노출량이 대칭이다.
- 자동/수동 슬라이드 이후 중심 정렬 테스트는 기존 Phase 9 결과를 유지한다.
- 실행: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`
- 기대: 현재 구현이 최초 layout 직후 우측 쏠림을 재현하면 RED가 되고, 초기 scroll position 재적용 또는 layout 완료 후 snap 보정 구현 후 GREEN이 된다.
- [x] **Task 10.2: 최초 렌더링 중심 정렬 보정 구현**
- 수정 파일: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/banner/BannerView.kt`
- 요구사항:
- `setItems()` 시점에 계산한 `currentAdapterPosition`이 item size, RecyclerView padding, spacing decoration 적용 이후에도 유지되도록 보정한다.
- `onSizeChanged()` 또는 layout size 적용 이후 복수 banner이면 최초 표시 position을 다시 적용하거나, `RecyclerView.post { ... }` 등 기존 구조에 맞는 최소 보정으로 첫 frame의 중심 정렬을 맞춘다.
- 보정은 최초 표시 또는 size 변경 시점에만 적용하고, 사용자가 수동으로 이동한 이후의 현재 위치를 불필요하게 되돌리지 않는다.
- 기존 자동 전환, 수동 스와이프, 무한 순환, counter 동기화 동작을 변경하지 않는다.
- 검증: Task 10.1 테스트와 Phase 9 중심/peek/counter 테스트, 기존 banner 전체 테스트가 모두 통과한다.
- [x] **Task 10.3: 실제 기기 수동 확인 기록**
- 확인 항목:
- 앱 진입 후 슬라이드 전 최초 banner가 화면 중앙에 보인다.
- 자동 또는 수동 슬라이드 후에도 중앙 정렬이 유지된다.
- 좌우 peek 노출량이 최초 표시와 슬라이드 이후 모두 대칭이다.
- 기록 위치: 이 문서 하단 `검증 기록` 섹션.
## 검증 기록
- 2026-05-27 Phase 1 완료: PRD `docs/prd/20260527_배너컴포넌트_prd.md`의 Goals, Visual Requirements, Behavior Requirements, Metrics가 계획의 Phase 2~7 task로 매핑됨을 확인했다. Figma 기준 `24:5525`에서 계획에 명시된 `1:1`, `screenWidth - 40dp`, 좌우 `20dp`, item 간격 `8dp`, 좌우 peek, `radius_14`, counter `top/right 14dp`, `01 / 20` 형식과 불일치하는 항목은 발견하지 못했다.
- 2026-05-27 Phase 1 패턴 확인: `AudioContentCardView``@JvmOverloads` custom view, `thumbnailView(): ImageView` 노출, `radius_14` outline clipping 패턴을 확인했다. `FeedAdapter``RecyclerView.Adapter`, click callback, image binding callback 패턴과 `FeedViewTest`의 Robolectric XML inflate test 패턴을 확인했다.
- 2026-05-27 Phase 1 리소스/의존성 확인: `colors.xml``white`, `gray_400`, `dimens.xml``spacing_8`, `spacing_14`, `spacing_20`, `radius_14`, `typography.xml``Typography.Body5` medium 14sp 토큰이 있음을 확인했다. `attrs.xml`은 현재 없어 Phase 3에서 생성 대상이다.
- 2026-05-27 Phase 1 구현 경계 확정: `app/build.gradle``androidx.recyclerview:recyclerview:1.4.0`, JUnit4, Robolectric 의존성이 이미 있으므로 신규 pager 의존성은 추가하지 않는다. 기존 `com.github.zhpanvip:bannerviewpager:3.5.7` 의존성은 존재하지만 이번 컴포넌트는 PRD/계획대로 래핑하거나 확장하지 않는다.
- 2026-05-27 Phase 2 진행: `BannerItem`, `BannerLayoutCalculator`, `BannerCounterFormatter`, `BannerState` 순수 Kotlin contract 파일과 `BannerLayoutCalculatorTest`, `BannerCounterFormatterTest`, `BannerStateTest` 단위 테스트 파일이 존재함을 확인했다.
- 2026-05-27 Phase 2 검증 준비: 순수 Kotlin contract 테스트는 `BannerLayoutCalculatorTest`, `BannerCounterFormatterTest`, `BannerStateTest` 범위로 실행했다.
- 2026-05-27 Phase 2 검증 완료: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerLayoutCalculatorTest" --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerCounterFormatterTest" --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerStateTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-27 RED: `BannerLayoutCalculatorTest`, `BannerCounterFormatterTest`, `BannerStateTest`는 최초 실행 시 `compileDebugUnitTestKotlin`에서 `BannerLayoutCalculator`, `BannerCounterFormatter`, `BannerState`, `BannerDisplayMode` 미해결 참조로 실패했다.
- 2026-05-27 GREEN: 순수 Kotlin contract 구현 후 대상 순수 테스트가 통과했다.
- 2026-05-27 LSP 진단: `kotlin-lsp`가 설치되어 있지 않아 Phase 2 Kotlin 파일의 LSP diagnostics는 사용할 수 없었다.
- 2026-05-27 Phase 3 리소스 추가: `bg_banner_counter.xml`, `bg_banner_preview_placeholder.xml`, `attrs.xml`, `item_banner.xml`, `view_banner.xml`을 추가했다. 기존 `bg_placeholder.xml`은 큰 vector icon이라 배너 영역 배경으로 재사용하지 않고, `radius_14` 기반 preview placeholder를 별도 생성했다.
- 2026-05-27 Phase 3 counter/view 구성: counter 배경은 `#B3000000` capsule로 만들고, `view_banner.xml``BannerView` root, horizontal `RecyclerView`, 우상단 counter overlay를 포함하도록 구성했다. counter text는 현재 index `white`, separator/total `gray_400` TextView로 분리했다.
- 2026-05-27 Phase 3 warning 조치: `android:clipToOutline`은 minSdk 23 기준 API 31 warning 대상이라 `item_banner.xml`에서 사용하지 않았다. 실제 radius clipping은 계획된 Phase 4.5 Kotlin outline provider 구현에서 처리한다.
- 2026-05-27 Phase 4 RED: `BannerViewTest` 추가 후 최초 실행 시 `view_banner.xml`이 참조하는 `BannerView` production class가 없어 `compileDebugJavaWithJavac`에서 실패함을 확인했다.
- 2026-05-27 Phase 4 구현: `BannerAdapter`는 0/1개 실제 count, 2개 이상 virtual position remap, image bind callback, item click callback, image radius clipping을 구현했다. `BannerView`는 XML child 연결, `RecyclerView + PagerSnapHelper`, `setItems`, click/image bind API, visibility, layout size/padding/spacing, counter 표시를 구현했다.
- 2026-05-27 Phase 4 RED/GREEN: 단일 item spacing 회귀 테스트 `배너 view는 단일 item이면 item 간격을 적용하지 않는다`를 추가해 RED를 확인한 뒤, 단일 item에서는 spacing을 `0`, 복수 item에서는 `8dp`로 적용하도록 수정해 GREEN을 확인했다.
- 2026-05-27 Phase 4 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"``./gradlew :app:assembleDebug`를 순차 실행해 모두 `BUILD SUCCESSFUL`을 확인했다. Kotlin LSP는 `kotlin-lsp`가 설치되어 있지 않아 diagnostics를 실행할 수 없었다.
- 2026-05-27 Phase 4 리뷰 수정: 리뷰에서 `BannerView(context)` 실제 사용 경로가 내부 layout을 inflate하지 않는 문제가 지적되어, `view_banner.xml``<merge>`로 변경하고 `BannerView` 생성 시 내부 layout을 inflate하도록 수정했다. `배너 view는 코드로 생성해도 내부 layout을 포함한다` 테스트로 RED를 확인한 뒤 GREEN을 확인했다.
- 2026-05-27 Phase 4 리뷰 수정 후 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"``./gradlew :app:assembleDebug`를 다시 실행해 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 5 RED: `BannerViewTest`에 자동 전환, 수동 drag timer reset, detach cleanup 테스트를 추가한 뒤 production 변경 전 실행해 자동 전환/drag reset 시나리오 실패를 확인했다.
- 2026-05-28 Phase 5 구현: `BannerView`에 main looper `Handler/Runnable` 기반 5초 자동 전환, `SCROLL_STATE_DRAGGING` 중 timer 취소, `SCROLL_STATE_IDLE` 후 재예약, `onAttachedToWindow` 시작, `onDetachedFromWindow` callback 제거를 추가했다. `BannerAdapter.startPosition()`으로 carousel 시작 위치를 virtual range 중앙에 맞췄다.
- 2026-05-28 Phase 5 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`, `./gradlew :app:assembleDebug` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 4.3 보완 RED: `BannerViewTest``layout_width="match_parent"`, `layout_height="wrap_content"` 측정 시 width `402dp`는 height `362dp`, width `360dp`는 height `320dp`가 되어야 한다는 회귀 테스트를 추가했고, production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행에서 두 테스트 실패를 확인했다.
- 2026-05-28 Phase 4.3 보완 구현: `BannerView.onMeasure()`에서 height measure spec이 `EXACTLY`가 아닐 때 measured width 기준 `BannerLayoutCalculator` 결과로 root measured height를 계산하도록 수정했다. 명시 height가 들어온 경우에는 부모 측정 제약을 유지한다.
- 2026-05-28 Phase 4.3 보완 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`, `./gradlew :app:assembleDebug` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 6 RED: `BannerViewTest`에 preview count/current index, preview image, null click listener 테스트를 추가했다. Production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행에서 preview counter assertion 실패와 preview item holder 미생성 실패를 확인했다.
- 2026-05-28 Phase 6 구현: `BannerView`에서 `bannerPreviewItemCount`, `bannerPreviewCurrentIndex`, `bannerPreviewImage` attrs를 읽어 preview sample item, counter, image resource를 반영하도록 보완했다. `TypedArray.use`는 Robolectric SDK 28에서 `AutoCloseable` 캐스팅 문제가 있어 `try/finally recycle()`로 처리했다. `BannerAdapter`는 preview image resource가 지정된 경우 bind 시 `ImageView`에 적용하도록 보완했다.
- 2026-05-28 Phase 6 GREEN: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 6 최종 검증: click callback 보강 테스트 추가 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"`, `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`, `./gradlew :app:assembleDebug` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 7 문서 갱신: `docs/agent-guides/build-test-style.md`의 단일 테스트 실행 섹션에 배너 컴포넌트 테스트 예시 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`를 추가했다.
- 2026-05-28 Phase 7 단위 테스트 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerLayoutCalculatorTest" --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerCounterFormatterTest" --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerStateTest" --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"``./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 7 리소스/빌드/스타일 검증: `./gradlew :app:assembleDebug``./gradlew :app:ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다. `ktlintCheck` 과정에서 배너 파일의 wrapping/blank line 지적과 테스트 소스의 unused import 지적을 최소 수정했다.
- 2026-05-28 Phase 7 수동 확인 기록: 실제 기기/Android Studio XML layout editor는 이 환경에서 열 수 없어 직접 시각 확인은 수행하지 못했다. 대신 Robolectric `BannerViewTest``assembleDebug`로 preview attrs 반영, counter 표시, radius outline provider, 0/1/2개 상태, 좌우 peek/spacing 계산, 자동 전환 5초, 수동 drag timer reset, attach/detach cleanup, 무한 순환 index 동작을 검증했다.
- 2026-05-28 Phase 8 RED: `BannerViewTest`에 counter `01 / 02` 공백/색상/14sp, lifecycle `ON_STOP`/`ON_START`, `setItems()` 목록 갱신 첫 번째 배너 재시작, 수동 경계/빠른 위치 변경 idle counter 동기화 테스트를 추가했다. Production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행 결과 의도한 4개 시나리오가 실패함을 확인했다.
- 2026-05-28 Phase 8 구현: `view_banner.xml` counter typography를 `Typography.Body5`로 맞추고, `BannerView`에서 separator를 ` / `로 표시하도록 보완했다. `findViewTreeLifecycleOwner()``DefaultLifecycleObserver`로 lifecycle stop/start timer 제어를 추가하고, detach 시 observer를 제거한다. `setItems()`는 새 목록을 첫 번째 배너 기준으로 초기화하며, idle 시 snap view가 없으면 현재 adapter position 기준으로 counter를 동기화한다.
- 2026-05-28 Phase 8 GREEN: 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 8 최종 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다. 실제 기기/Android Studio XML layout editor는 이 환경에서 열 수 없어 직접 시각 확인은 수행하지 못했고, Robolectric/빌드 검증으로 counter 공백/typography, lifecycle stop/start, 목록 갱신 초기화, 수동 경계/연속 위치 변경 counter 동기화를 확인했다.
- 2026-05-28 Phase 9 RED: `BannerViewTest`에 carousel item spacing 좌우 대칭 검증과 counter item 내부 우상단 기준 margin 검증을 추가했다. Production 변경 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행 결과 `배너 view는 정사각형 item 크기와 좌우 padding 및 간격을 적용한다`, `배너 view는 carousel item 간격을 좌우 대칭으로 적용한다`, `배너 view counter는 현재 item 내부 우상단 기준 margin을 적용한다` 3개 테스트 실패를 확인했다.
- 2026-05-28 Phase 9 구현: `BannerSpacingDecoration`이 복수 banner spacing `8dp`를 좌우 `4dp`씩 대칭 적용하도록 수정했다. `BannerView.applyLayoutSize()`에서 counter end margin을 `sideInset 20dp + item 내부 margin 14dp = 34dp`로 동적 보정해 root 우측이 아닌 현재 banner item 내부 우상단 기준에 맞췄다.
- 2026-05-28 Phase 9 GREEN: 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 9 수동 확인 기록: 실제 기기/Android Studio XML layout editor는 이 환경에서 열 수 없어 직접 시각 확인은 수행하지 못했다. 대신 Robolectric 검증으로 현재 banner 중심 기준 spacing 좌우 대칭, 단일 item spacing 0 유지, counter top `14dp`, counter end `34dp` 적용을 확인했다.
- 2026-05-28 Phase 9 최종 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 10 RED: `BannerViewTest`에 최초 `measure/layout` 직후 현재 adapter position의 item 중심이 `BannerView` 중심과 일치하는지 검증하는 회귀 테스트를 추가했다. Production 보정 전 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행 결과 `배너 view는 최초 layout 직후 현재 item 중심을 view 중심에 맞춘다` 테스트 실패를 확인했다.
- 2026-05-28 Phase 10 구현: `setItems()` 이후 최초 layout size 적용 시점에만 현재 adapter position을 다시 정렬하도록 `shouldAlignCurrentPositionAfterLayout` 플래그를 추가했다. 복수 banner에서는 `LinearLayoutManager.scrollToPositionWithOffset()`에 decoration 절반 offset을 적용해 초기 child content 중심을 view 중심에 맞추고, 수동 이동 이후에는 해당 보정을 반복하지 않도록 했다.
- 2026-05-28 Phase 10 GREEN: 구현 후 `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.BannerViewTest"` 실행 결과 `BUILD SUCCESSFUL`을 확인했다.
- 2026-05-28 Phase 10 수동 확인 기록: 실제 기기/Android Studio XML layout editor는 이 환경에서 열 수 없어 직접 시각 확인은 수행하지 못했다. 대신 Robolectric 검증으로 최초 layout 직후 현재 item 중심과 view 중심 일치, 좌우 `20dp` 기준 위치, 기존 자동/수동 슬라이드 테스트 유지를 확인했다.
- 2026-05-28 Phase 10 최종 검증: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.banner.*"`, `./gradlew :app:assembleDebug`, `./gradlew :app:ktlintCheck` 실행 결과 모두 `BUILD SUCCESSFUL`을 확인했다.