docs(chat): 채팅 탭 구현 계획을 추가한다

This commit is contained in:
2026-06-09 23:13:59 +09:00
parent bb4d290ca1
commit 86e18a1f7c
2 changed files with 733 additions and 0 deletions

View File

@@ -0,0 +1,474 @@
# 채팅 탭 페이지 Plan / Task
> **For agentic workers:** 구현 시 `superpowers:subagent-driven-development` 또는 `superpowers:executing-plans`를 사용해 task 단위로 진행한다. 각 단계는 체크박스(`- [ ]`)로 추적하고, 완료 즉시 `- [x]`로 갱신한다.
**Goal:** `ChatMainFragment`에 Figma `177:3466` 기준 채팅 탭 페이지를 구성하고 `GET /api/v2/chat/rooms` 목록, filter, cursor pagination을 연결한다.
**Architecture:** 채팅 탭 전용 API/Repository/ViewModel/DTO/UI model/Adapter는 `kr.co.vividnext.sodalive.v2.main.chat` 하위에 둔다. 기존 `CapsuleTabBarView`, `view_title_bar_default.xml`, `MainV2Activity` 하단 내비게이션, `ChatRoomActivity.newIntent(context, roomId)`, Coil 이미지 로딩 패턴을 우선 재사용하고, 없는 채팅방 list item과 Direct badge만 최소 신규 UI로 만든다.
**Tech Stack:** Kotlin, Android XML Views, ViewBinding, RecyclerView, RxJava3, Retrofit, Gson, Koin, Coil, Robolectric/local unit test.
---
## 전제와 성공 기준
- PRD: `docs/20260609_채팅_탭_페이지/prd.md`
- Figma: `177:3466`
- `filter` query는 `ALL`, `AI`, `DM` 중 하나만 사용한다.
- `cursor` query는 다음 페이지 요청에 사용한다. 첫 페이지는 `cursor=null`로 요청한다.
- 현재 선택되지 않은 `CapsuleTabBarView` tab 터치 시 첫 페이지 API를 호출하고 기존 목록을 clear한 뒤 새 목록으로 세팅한다.
- `hasMore=true``nextCursor`가 제공되면 스크롤 pagination으로 다음 페이지를 append한다.
- unread dot은 표시하지 않는다.
- `chatType``AI`, `DM` 문자열이다.
- `lastMessageAt`은 ISO-8601 문자열이며 디바이스 timezone으로 변환 후 표시한다.
- 일주일까지는 상대 시간 문구로 표시하고, 그보다 오래된 올해 메시지는 locale별 날짜 포맷, 다른 연도 메시지는 `yyyy.MM.dd`로 표시한다.
- `chatType=AI` item 클릭은 `ChatRoomActivity.newIntent(context, roomId)`로 연결한다.
- `chatType=DM` item 클릭과 플로팅 버튼 클릭은 이번 범위에서 실제 이동을 연결하지 않는다.
- 구현 완료 후 최소 다음 명령을 실행한다.
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.CapsuleTabSelectionStateTest"`
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- `./gradlew :app:ktlintCheck`
---
## 파일 구조
- Rename: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/HomeChatModels.kt` -> `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomModels.kt`
- `ChatRoomListPageResponse`, `ChatRoomListItemResponse` DTO를 유지한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomApi.kt`
- `GET /api/v2/chat/rooms` Retrofit endpoint를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomRepository.kt`
- API 호출을 위임한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomFilter.kt`
- `ALL`, `AI`, `DM` filter와 tab index 매핑을 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomUiModels.kt`
- `ChatRoomListUiItem`, `ChatRoomListUiState`를 정의한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomMappers.kt`
- DTO를 UI item으로 변환한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomTimeTextFormatter.kt`
- ISO-8601 시간 문자열을 화면 표시 문자열로 변환한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt`
- filter, first page load, pagination, loading/error state를 관리한다.
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ui/ChatRoomListAdapter.kt`
- 채팅방 RecyclerView adapter를 구현한다.
- Create: `app/src/main/res/layout/item_v2_chat_room.xml`
- 채팅방 list item UI를 정의한다.
- Create: `app/src/main/res/drawable/bg_chat_direct_badge.xml`
- Direct badge 배경을 정의한다.
- Create: `app/src/main/res/drawable/bg_chat_floating_button.xml`
- 플로팅 버튼 배경을 정의한다.
- Modify: `app/src/main/res/layout/view_title_bar_default.xml`
- 우측 아이콘 개수가 화면별로 달라질 수 있도록 action container를 추가한다.
- Modify: `app/src/main/res/layout/fragment_v2_main_chat.xml`
- title bar, capsule tab bar, RecyclerView, floating button을 배치한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- ViewModel observe, adapter, filter tab, pagination, AI item click을 연결한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt`
- selected tab 텍스트 색상을 검정으로 표시한다.
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- `ChatRoomApi`, `ChatRoomRepository`, `ChatMainViewModel`을 등록한다.
- Modify: `app/src/main/res/values/strings.xml`
- Modify: `app/src/main/res/values-en/strings.xml`
- Modify: `app/src/main/res/values-ja/strings.xml`
- 채팅 탭 title/filter, Direct badge, 상대 시간 string을 추가한다.
- Test Create:
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomFilterTest.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomTimeTextFormatterTest.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomMapperTest.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomListAdapterTest.kt`
- `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
---
### Phase 1: 기존 구조 확인과 범위 고정
- [x] **Task 1.1: 기존 채팅 탭, 메인 내비게이션, 채팅방 진입 구조 확인**
- 확인: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- 확인: `app/src/main/res/layout/fragment_v2_main_chat.xml`
- 확인: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
- 확인: `app/src/main/res/layout/activity_main_v2.xml`
- 확인: `app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt`
- 검증: 하단 nav는 `MainV2Activity`가 담당하고, AI item 클릭은 `ChatRoomActivity.newIntent(context, roomId)`를 사용한다.
- [x] **Task 1.2: 기존 재사용 위젯/리소스 확인**
- 확인: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt`
- 확인: `app/src/main/res/layout/view_capsule_tab_bar.xml`
- 확인: `app/src/main/res/layout/view_title_bar_default.xml`
- 확인: `app/src/main/res/layout/view_title_bar_home.xml`
- 확인: `app/src/main/res/drawable-mdpi/ic_bar_cash.png`
- 확인: `app/src/main/res/drawable-mdpi/ic_bar_search.png`
- 확인: `app/src/main/res/drawable-xxhdpi/ic_plus_no_bg.png`
- 검증: `view_title_bar_default.xml`은 제목형 title bar로 재사용하고, 우측 action 영역만 가변 아이콘 구조로 확장한다.
---
### Phase 2: DTO/API/Repository 네이밍과 계약 작성
- [ ] **Task 2.1: `HomeChatModels.kt` 파일명 정리**
- Rename: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/HomeChatModels.kt` -> `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomModels.kt`
- 유지:
- `ChatRoomListPageResponse`
- `ChatRoomListItemResponse`
- 검증 명령: `./gradlew :app:compileDebugKotlin`
- 기대 결과: 파일명 변경 후 기존 import가 없거나 갱신되어 Kotlin compile이 성공한다.
- [ ] **Task 2.2: 채팅방 API/Repository 작성**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomApi.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/ChatRoomRepository.kt`
- API 계약:
- `@GET("/api/v2/chat/rooms")`
- `@Header("Authorization") authHeader: String`
- `@Query("filter") filter: String`
- `@Query("cursor") cursor: String?`
- return: `Single<ApiResponse<ChatRoomListPageResponse>>`
- Repository 계약:
- `fun getChatRooms(token: String, filter: String, cursor: String?): Single<ApiResponse<ChatRoomListPageResponse>>`
- 검증 명령: `./gradlew :app:compileDebugKotlin`
- 기대 결과: Retrofit annotation/import 포함 컴파일 성공.
- [ ] **Task 2.3: Koin DI 등록**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- 추가:
- import `ChatRoomApi`
- import `ChatRoomRepository`
- import `ChatMainViewModel`
- `networkModule`: `single { ApiBuilder().build(get(), ChatRoomApi::class.java) }`
- `repositoryModule`: `factory { ChatRoomRepository(get()) }`
- `viewModelModule`: `viewModel { ChatMainViewModel(get()) }`
- 검증 명령: `./gradlew :app:compileDebugKotlin`
- 기대 결과: Koin 등록과 import 컴파일 성공.
---
### Phase 3: Filter, 시간 formatter, mapper 작성
- [ ] **Task 3.1: Filter 매핑 RED 테스트 작성**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomFilterTest.kt`
- 테스트 케이스:
- index `0` -> `ChatRoomFilter.ALL`
- index `1` -> `ChatRoomFilter.AI`
- index `2` -> `ChatRoomFilter.DM`
- 각 filter의 API value는 `ALL`, `AI`, `DM`
- 현재 선택된 index를 다시 선택하면 같은 filter를 반환하되 ViewModel에서 중복 호출하지 않는다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomFilterTest"`
- 기대 결과: `ChatRoomFilter` 미구현으로 RED 실패.
- [ ] **Task 3.2: Filter 모델 구현**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomFilter.kt`
- 구현:
- `enum class ChatRoomFilter(val apiValue: String) { ALL("ALL"), AI("AI"), DM("DM") }`
- `fun ChatRoomFilter.Companion.fromTabIndex(index: Int): ChatRoomFilter`
- 유효하지 않은 index는 `ALL`로 처리한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomFilterTest"`
- 기대 결과: PASS.
- [ ] **Task 3.3: 시간 formatter RED 테스트 작성**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomTimeTextFormatterTest.kt`
- 테스트 기준:
- 기준 now: `2026-06-09T12:00:00Z`
- timezone: `Asia/Seoul`
- locale: `ko`, `en`, `ja`
- 테스트 케이스:
- 30초 전 -> `screen_chat_time_just_now`
- 3분 전 -> `screen_chat_time_minutes`
- 2시간 전 -> `screen_chat_time_hours`
- 7일 이내 -> `screen_chat_time_days`
- 8일 전이고 같은 연도/ko -> `6월 1일`
- 8일 전이고 같은 연도/en -> `Jun 1`
- 8일 전이고 같은 연도/ja -> `6月1日`
- 전년도 -> `2025.12.31`
- timezone 변환 결과 로컬 날짜가 달라지는 ISO-8601 입력을 올바르게 처리한다.
- parse 실패 또는 blank -> `screen_chat_time_just_now`
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomTimeTextFormatterTest"`
- 기대 결과: formatter 미구현으로 RED 실패.
- [ ] **Task 3.4: 다국어 string과 시간 formatter 구현**
- Modify: `app/src/main/res/values/strings.xml`
- Modify: `app/src/main/res/values-en/strings.xml`
- Modify: `app/src/main/res/values-ja/strings.xml`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomTimeTextFormatter.kt`
- string 추가:
- `screen_chat_time_just_now`
- `screen_chat_time_minutes`
- `screen_chat_time_hours`
- `screen_chat_time_days`
- 구현:
- `fun formatChatRoomLastMessageTime(context: Context, isoText: String?, nowMillis: Long = System.currentTimeMillis(), timeZone: TimeZone = TimeZone.getDefault(), locale: Locale = Locale.getDefault()): String`
- ISO-8601 offset 포함 문자열을 우선 파싱한다.
- `diff < 1분`: just now
- `diff < 1시간`: minutes
- `diff < 1일`: hours
- `diff < 8일`: days
- 같은 연도: locale별 `M월 d일`, `MMM d`, `M月d日`
- 다른 연도: `yyyy.MM.dd`
- minSdk 23과 desugaring 미설정 상태를 고려해 `SimpleDateFormat`, `Calendar`, `TimeZone` 기반으로 구현한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomTimeTextFormatterTest"`
- 기대 결과: PASS.
- [ ] **Task 3.5: DTO -> UI model mapper RED 테스트 작성**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomMapperTest.kt`
- 테스트 케이스:
- `chatType="AI"`는 Direct badge 미표시 item으로 매핑한다.
- `chatType="DM"`은 Direct badge 표시 item으로 매핑한다.
- `lastMessageAt`은 formatter 결과 문자열로 매핑한다.
- `roomId`, `targetName`, `targetImageUrl`, `lastMessage`는 그대로 전달한다.
- 알 수 없는 `chatType`은 표시 대상에서 제외한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomMapperTest"`
- 기대 결과: UI model/mapper 미구현으로 RED 실패.
- [ ] **Task 3.6: UI model과 mapper 구현**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomUiModels.kt`
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/model/ChatRoomMappers.kt`
- 구현:
- `enum class ChatRoomType { AI, DM }`
- `data class ChatRoomListUiItem(roomId: Long, chatType: ChatRoomType, targetName: String, targetImageUrl: String, lastMessage: String, lastMessageTimeText: String, showDirectBadge: Boolean)`
- `sealed class ChatRoomListUiState`
- `Loading`
- `Content(val items: List<ChatRoomListUiItem>, val isAppending: Boolean = false)`
- `Empty`
- `Error(val message: String?)`
- `fun ChatRoomListItemResponse.toUiItem(context: Context): ChatRoomListUiItem?`
- `fun List<ChatRoomListItemResponse>.toUiItems(context: Context): List<ChatRoomListUiItem>`
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomMapperTest"`
- 기대 결과: PASS.
---
### Phase 4: ViewModel pagination/filter 동작 작성
- [ ] **Task 4.1: ViewModel RED 테스트 작성**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModelTest.kt`
- 테스트 케이스:
- 초기 `loadFirstPage()``filter=ALL`, `cursor=null`로 API를 호출한다.
- 다른 filter 선택 시 기존 items를 clear하고 `cursor=null` 첫 페이지를 요청한다.
- 현재 선택된 filter를 다시 선택하면 API를 재호출하지 않는다.
- 첫 페이지 success + rooms 있음 -> `Content(items)`
- 첫 페이지 success + rooms empty -> `Empty`
- `hasMore=true`, `nextCursor` 있음 상태에서 `loadNextPage()`는 다음 cursor로 호출하고 items를 append한다.
- `hasMore=false``loadNextPage()`가 API를 호출하지 않는다.
- append loading 중 중복 `loadNextPage()` 호출은 무시한다.
- API failure/data null/Throwable은 `Error`와 unknown error toast를 발생시킨다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"`
- 기대 결과: `ChatMainViewModel` 미구현으로 RED 실패.
- [ ] **Task 4.2: ViewModel 구현**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainViewModel.kt`
- 구현:
- `currentFilter: ChatRoomFilter = ChatRoomFilter.ALL`
- `currentItems: List<ChatRoomListUiItem>`
- `nextCursor: String?`
- `hasMore: Boolean`
- `isLoading`, `isAppending`, `chatRoomStateLiveData`, `toastLiveData`
- `loadFirstPage(filter: ChatRoomFilter = currentFilter)`
- `selectFilter(filter: ChatRoomFilter)`
- `loadNextPage()`
- token은 `"Bearer ${SharedPreferenceManager.token}"` 형태로 전달한다.
- first page 요청 전 state는 `Loading` 및 items clear를 반영한다.
- append 요청은 기존 items를 유지하고 성공 시 뒤에 붙인다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainViewModelTest"`
- 기대 결과: PASS.
---
### Phase 5: 채팅방 list item UI와 Adapter 작성
- [ ] **Task 5.1: Direct badge와 item layout 추가**
- Create: `app/src/main/res/drawable/bg_chat_direct_badge.xml`
- Create: `app/src/main/res/layout/item_v2_chat_room.xml`
- Modify: `app/src/main/res/values/strings.xml`
- Modify: `app/src/main/res/values-en/strings.xml`
- Modify: `app/src/main/res/values-ja/strings.xml`
- 구현 기준:
- item root height는 content wrap, vertical padding `@dimen/spacing_14`
- profile image `58dp`, 원형 표시
- name `18sp` bold, white, maxLines 1
- Direct badge: `soda_400`, radius `4dp`, Pattaya font, text `Direct`
- time: `14sp`, `gray_500`, right align
- last message: `16sp`, `gray_500`, maxLines 1, ellipsize end
- unread dot view는 만들지 않는다.
- 검증 명령: `./gradlew :app:mergeDebugResources`
- 기대 결과: 신규 layout/drawable/string resource merge 성공.
- [ ] **Task 5.2: Adapter RED 테스트 작성**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatRoomListAdapterTest.kt`
- 테스트 케이스:
- DM item bind 시 Direct badge가 `VISIBLE`
- AI item bind 시 Direct badge가 `GONE`
- name, lastMessage, lastMessageTimeText가 TextView에 표시된다.
- item 클릭 시 bound `ChatRoomListUiItem`을 listener로 전달한다.
- layout에 unread dot id가 존재하지 않는다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomListAdapterTest"`
- 기대 결과: Adapter 미구현으로 RED 실패.
- [ ] **Task 5.3: Adapter 구현**
- Create: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ui/ChatRoomListAdapter.kt`
- 구현:
- `RecyclerView.Adapter`
- `submitItems(items: List<ChatRoomListUiItem>)`
- `onItemClick: (ChatRoomListUiItem) -> Unit`
- Coil `loadUrl` 또는 기존 이미지 로딩 extension을 사용해 profile image를 로딩한다.
- `showDirectBadge`로 Direct badge visibility를 제어한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatRoomListAdapterTest"`
- 기대 결과: PASS.
---
### Phase 6: 채팅 탭 layout과 CapsuleTab 색상 보정
- [ ] **Task 6.1: 기본 title bar 가변 action 영역 RED 테스트 작성**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
- 테스트 케이스:
- `view_title_bar_default.xml``tv_title_bar_title`이 존재한다.
- `view_title_bar_default.xml``ll_title_bar_actions`가 존재한다.
- 기존 호환을 위해 `iv_title_bar_menu` id가 유지된다.
- `ll_title_bar_actions`는 우측 아이콘을 2개 이상 담을 수 있는 horizontal container다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`
- 기대 결과: action container 미구현으로 RED 실패.
- [ ] **Task 6.2: `view_title_bar_default.xml` 가변 action 영역 구현**
- Modify: `app/src/main/res/layout/view_title_bar_default.xml`
- 구현:
- 기존 `tv_title_bar_title`은 유지한다.
- 기존 `iv_title_bar_menu` id는 유지한다.
- `iv_title_bar_menu``LinearLayout` id `ll_title_bar_actions` 내부로 이동한다.
- `ll_title_bar_actions`는 horizontal, center_vertical, 우측 정렬 가능한 구조로 둔다.
- 추가 action icon은 Fragment에서 `ll_title_bar_actions.addView(...)`로 붙일 수 있게 한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"``./gradlew :app:mergeDebugResources`
- 기대 결과: PASS.
- [ ] **Task 6.3: Fragment layout RED 테스트 작성**
- Create: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
- 테스트 케이스:
- `fragment_v2_main_chat.xml` root background가 `@color/black`
- `view_title_bar_default` include가 존재한다.
- `view_capsule_tab_bar` include가 title bar 아래에 존재한다.
- `rv_chat_rooms`가 capsule tab 아래, parent bottom까지 constraint 된다.
- `btn_chat_floating`이 우측 하단에 존재한다.
- layout에 bottom navigation view가 존재하지 않는다.
- layout에 unread dot view id가 존재하지 않는다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`
- 기대 결과: layout 미구현으로 RED 실패.
- [ ] **Task 6.4: Fragment layout 구현**
- Create: `app/src/main/res/drawable/bg_chat_floating_button.xml`
- Modify: `app/src/main/res/layout/fragment_v2_main_chat.xml`
- 구현:
- root를 `ConstraintLayout`으로 변경한다.
- `view_title_bar_default`: height `60dp`, title은 Fragment에서 `대화`로 설정한다.
- `view_capsule_tab_bar`: height `52dp`, title bar 아래
- `rv_chat_rooms`: `0dp x 0dp`, capsule tab 아래부터 parent bottom, `clipToPadding=false`, bottom padding은 하단 nav/미니플레이어 겹침 방지 기준으로 충분히 둔다.
- `btn_chat_floating`: 우측 하단 원형 `soda_400`, plus icon, 실제 click action 없음
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"``./gradlew :app:mergeDebugResources`
- 기대 결과: PASS.
- [ ] **Task 6.5: CapsuleTab selected 텍스트 색상 테스트 보강**
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabSelectionStateTest.kt` 또는 신규 `app/src/test/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarViewTest.kt`
- 테스트 케이스:
- selected tab은 white background와 black text를 사용한다.
- normal tab은 black background와 white text를 사용한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.*CapsuleTab*"`
- 기대 결과: 현재 `CapsuleTabBarView`가 selected text도 white로 설정해 RED 실패.
- [ ] **Task 6.6: CapsuleTab selected 텍스트 색상 구현**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt`
- 구현:
- selected이면 `R.color.black`
- normal이면 `R.color.white`
- 기존 background drawable 정책은 유지한다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.*CapsuleTab*"`
- 기대 결과: PASS.
---
### Phase 7: `ChatMainFragment` 화면 동작 연결
- [ ] **Task 7.1: Fragment source 계약 테스트 작성**
- Modify: `app/src/test/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragmentLayoutTest.kt`
- 테스트 케이스:
- `ChatMainFragment.kt``ChatMainViewModel`을 주입한다.
- include된 `view_title_bar_default`의 title을 `대화`로 설정한다.
- `iv_title_bar_menu`에는 `ic_bar_cash`를 설정하고, `ll_title_bar_actions`에는 `ic_bar_search` ImageView를 추가한다.
- `CapsuleTabBarView.setMenus``전체`, `AI 채팅`, `DM`을 설정한다.
- tab 선택 listener에서 `ChatRoomFilter.fromTabIndex(index)`를 사용한다.
- RecyclerView에 `LinearLayoutManager``ChatRoomListAdapter`를 연결한다.
- scroll listener에서 끝에 가까워지면 `viewModel.loadNextPage()`를 호출한다.
- AI item 클릭은 `ChatRoomActivity.newIntent(requireContext(), item.roomId)`를 사용한다.
- DM item 클릭과 floating button click은 startActivity를 호출하지 않는다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`
- 기대 결과: Fragment 동작 미구현으로 RED 실패.
- [ ] **Task 7.2: Fragment 동작 구현**
- Modify: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- 구현:
- `private val viewModel: ChatMainViewModel by viewModel()`
- `private val chatRoomListAdapter = ChatRoomListAdapter { onChatRoomClick(it) }`
- `binding.viewChatTitleBar.tvTitleBarTitle.setText(R.string.tab_chat)`
- `binding.viewChatTitleBar.ivTitleBarMenu.setImageResource(R.drawable.ic_bar_cash)`
- `binding.viewChatTitleBar.llTitleBarActions.addView(ImageView(requireContext()).apply { setImageResource(R.drawable.ic_bar_search) })` 형태로 search action을 추가한다.
- `binding.viewChatFilterTabs.root.setMenus(listOf(...), selectedIndex = 0)`
- 현재 선택되지 않은 tab 선택 시 `viewModel.selectFilter(ChatRoomFilter.fromTabIndex(index))`
- `onViewCreated`에서 `viewModel.loadFirstPage()`
- state observe:
- `Content` -> adapter submit
- `Empty`, `Error` -> adapter empty
- `Loading` -> first page loading 표시
- `isLoading`은 기존 `LoadingDialog` 패턴 사용
- scroll pagination threshold는 하단 3개 전 기준으로 `viewModel.loadNextPage()`
- `onChatRoomClick`: `AI``ChatRoomActivity`로 이동, `DM`은 return
- floating button click listener는 연결하지 않거나 no-op으로 둔다.
- 검증 명령: `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.ChatMainFragmentLayoutTest"`
- 기대 결과: PASS.
---
### Phase 8: 통합 검증과 문서 기록
- [ ] **Task 8.1: 리소스/컴파일 검증**
- 실행:
- `./gradlew :app:mergeDebugResources`
- `./gradlew :app:compileDebugKotlin`
- 기대 결과:
- 모든 신규 layout/drawable/string/binding 생성 성공
- Kotlin compile 성공
- [ ] **Task 8.2: 단위 테스트 검증**
- 실행:
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.main.chat.*"`
- `./gradlew :app:testDebugUnitTest --tests "kr.co.vividnext.sodalive.v2.widget.*CapsuleTab*"`
- 기대 결과:
- 채팅 탭 filter/formatter/mapper/ViewModel/Adapter/Layout 테스트 통과
- CapsuleTab 기존 테스트와 색상 보정 테스트 통과
- [ ] **Task 8.3: ktlint 검증**
- 실행: `./gradlew :app:ktlintCheck`
- 기대 결과: ktlint error 없음
- [ ] **Task 8.4: 검증 기록 누적**
- Modify: `docs/20260609_채팅_탭_페이지/plan-task.md`
- Modify: `docs/20260609_채팅_탭_페이지/prd.md`
- 기록:
- 무엇/왜/어떻게 변경했는지
- 실행한 명령
- 각 명령 결과
- 실패가 있었다면 실패 원인과 후속 조치
- 검증: 기존 Verification Log를 삭제하거나 덮어쓰지 않고 하단에 새 항목으로 누적한다.
---
## 구현 제외 체크리스트
- [ ] unread dot view, drawable, binding을 추가하지 않는다.
- [ ] `ChatMainFragment` 내부에 하단 `BottomNavigationView`를 추가하지 않는다.
- [ ] `chatType=DM` item 클릭 이동을 이번 범위에서 구현하지 않는다.
- [ ] 플로팅 버튼 클릭 이동을 이번 범위에서 구현하지 않는다.
- [ ] WebSocket/SSE, pull-to-refresh, skeleton/shimmer를 추가하지 않는다.
- [ ] API schema 필드명을 임의 변경하지 않는다.
---
## Verification Log
- 2026-06-09: `docs/20260609_채팅_탭_페이지/prd.md`, `docs/agent-guides/work-plan-docs.md`, 기존 `ChatMainFragment`, `fragment_v2_main_chat.xml`, `HomeChatModels.kt`, `HomeRecommendationApi/Repository/ViewModel`, `AppDI`, `CapsuleTabBarView`, `ChatRoomActivity.newIntent`, `RelativeTimeFormatter`, locale string 리소스를 확인해 plan-task를 작성했다.
- 2026-06-09: 이번 단계는 계획 문서 작성만 수행했으며 구현/빌드/테스트는 실행하지 않았다.
- 2026-06-09: 사용자 피드백에 따라 신규 `view_title_bar_chat.xml` 생성 계획을 제거하고, 기존 `view_title_bar_default.xml``ll_title_bar_actions` 가변 action container를 추가해 cash/search icon을 구성하는 계획으로 수정했다.
- 2026-06-09: Phase 1.1 확인 완료. `ChatMainFragment.kt`는 현재 `BaseFragment<FragmentV2MainChatBinding>`만 상속하는 빈 구조이고, `fragment_v2_main_chat.xml`은 black `FrameLayout`만 가진 초기 상태다. `MainV2Activity.kt``BottomNavigationView` item 선택과 `MainV2Tab.CHAT -> ChatMainFragment()` 전환을 담당하며, `activity_main_v2.xml``bottom_navigation`은 Activity 레벨에 존재한다. `ChatRoomActivity.newIntent(context, roomId)``extra_room_id`를 담아 채팅방 Activity로 이동하는 기존 진입점임을 확인했다.
- 2026-06-09: Phase 1.2 확인 완료. `CapsuleTabBarView.kt``setMenus`, `selectTab`, `setOnTabSelectedListener`를 제공하고 `view_capsule_tab_bar.xml`은 horizontal scroll container를 포함한다. `view_title_bar_default.xml`은 제목형 title bar로 재사용 가능하나 현재 `tv_title_bar_title`, `iv_title_bar_menu`만 있고 가변 action container는 아직 없다. `view_title_bar_home.xml``ic_bar_cash`, `ic_bar_search` 우측 아이콘 배치 예시를 제공하며, `ic_plus_no_bg` 리소스는 기존 리소스로 참조 가능함을 확인했다. Phase 1은 구조 확인/문서 갱신만 수행했으므로 빌드/테스트는 실행하지 않았다.

View File

@@ -0,0 +1,259 @@
# PRD: 채팅 탭 페이지
## 1. Overview
`ChatMainFragment`에 Figma `chat_001` 기준 채팅 탭 페이지를 구성하고, `GET /api/v2/chat/rooms` 응답을 채팅방 목록 UI로 표시한다.
---
## 2. Problem
- `ChatMainFragment`는 현재 `FragmentV2MainChatBinding`만 연결된 빈 Fragment다.
- Figma `177:3466`에는 대화 탭의 상단 타이틀, 필터 탭, 채팅방 목록, 플로팅 액션 버튼이 정의되어 있지만 Android 화면에 아직 반영되어 있지 않다.
- 기존 `v2` 위젯 중 일부는 재사용할 수 있으나, Figma의 채팅방 list item과 Direct badge는 전용 위젯이 없다.
- API 응답 모델은 `HomeChatModels.kt`에 있으나 채팅 탭 스펙에는 `ChatRoomList*` 계열 이름이 더 명확하다.
---
## 3. Goals
- `ChatMainFragment`에서 Figma `chat_001`과 동일한 채팅 탭 페이지 골격을 제공한다.
- 기존 위젯과 리소스를 우선 재사용하고, 없는 UI만 최소 신규 위젯으로 만든다.
- `GET /api/v2/chat/rooms``filter`, `cursor` 파라미터와 함께 호출해 채팅방 목록을 표시한다.
- 필터 탭 `전체`, `AI 채팅`, `DM`을 제공하고 선택 상태에 맞게 첫 페이지 API를 다시 호출한다.
- 채팅방 item 터치 시 `chatType=AI`는 기존 채팅방 화면으로 이동한다.
---
## 4. Non-Goals
- 채팅방 상세 화면, 메시지 송수신, 채팅방 생성 flow 자체는 구현하지 않는다.
- `chatType=DM` item 클릭 시 이동할 DM 페이지 생성 및 연결은 다음 범위에서 진행한다.
- 하단 메인 내비게이션은 `MainV2Activity`의 기존 `BottomNavigationView`를 사용하며 `ChatMainFragment` 내부에 새로 만들지 않는다.
- Figma의 iOS StatusBar는 Android 앱에서 별도 View로 만들지 않는다.
- API 스키마를 임의 변경하지 않는다.
- unread dot 표시는 이번 PRD 범위에 포함하지 않는다.
- 실시간 unread 갱신, WebSocket/SSE, pull-to-refresh, skeleton/shimmer는 이번 PRD 범위에 포함하지 않는다.
- Analytics/logging은 별도 요구가 없으므로 추가하지 않는다.
---
## 5. Target Users
- 메인 하단 `대화` 탭에서 AI 채팅방과 DM 채팅방을 확인하려는 앱 사용자.
- `kr.co.vividnext.sodalive.v2.main.chat` 영역을 구현/유지보수하는 Android 개발자.
---
## 6. User Stories
- 사용자는 `대화` 탭을 눌렀을 때 최근 대화 목록을 바로 보고 싶다.
- 사용자는 `전체`, `AI 채팅`, `DM` 필터로 채팅방 유형을 빠르게 구분하고 싶다.
- 사용자는 각 채팅방에서 상대 프로필, 이름, 마지막 메시지, 마지막 메시지 시간을 확인하고 싶다.
- 사용자는 새 대화를 시작하는 플로팅 버튼을 발견할 수 있어야 한다.
---
## 7. Core Features
### Chat Tab Layout
`ChatMainFragment`의 루트 화면을 Figma `177:3466` 기준으로 구성한다.
#### Requirements
- 전체 배경은 `@color/black`을 사용한다.
- 상단에는 높이 `60dp` 수준의 타이틀바를 배치하고 제목은 `대화`로 표시한다.
- 타이틀바 우측에는 기존 `ic_bar_cash`, `ic_bar_search` 리소스를 재사용한다.
- Figma의 하단 nav는 `MainV2Activity`의 기존 `BottomNavigationView`가 담당하므로 Fragment layout에는 포함하지 않는다.
- content 영역은 타이틀바 아래 필터 탭과 채팅방 목록으로 구성한다.
- 플로팅 액션 버튼은 우측 하단, 하단 내비게이션과 겹치지 않는 위치에 표시한다.
#### Edge Cases
- Android system bar inset은 `MainV2Activity.overrideRootWindowInsets()`의 기존 처리를 따른다.
- 미니 플레이어가 표시될 수 있으므로 플로팅 버튼과 목록 하단 padding은 `activity_main_v2.xml` 구조를 고려해 구현 계획에서 확정한다.
### Chat Room Filter Tabs
채팅방 유형 필터를 제공한다.
#### Requirements
- 탭 메뉴는 `전체`, `AI 채팅`, `DM` 순서로 표시한다.
- Figma의 pill 형태와 가장 가까운 기존 `CapsuleTabBarView`를 우선 재사용한다.
- selected 상태는 흰색 배경/검정 텍스트, normal 상태는 검정 배경/회색 stroke/흰색 텍스트로 표시되어야 한다.
- 기존 `CapsuleTabBarView`가 selected 텍스트 색상을 검정으로 처리하지 못하면 최소 범위로 위젯을 확장한다.
- 현재 선택되어 있지 않은 탭을 터치하면 해당 `filter` 값으로 첫 페이지 API를 호출한다.
- 탭은 서버 목록 조회의 filter 역할을 담당한다.
- 첫 페이지 로딩 시 화면에 표시 중인 모든 기존 목록 데이터를 지우고 새 응답 데이터로 다시 세팅한다.
#### Edge Cases
- API 요청 `filter``ALL`, `AI`, `DM` 중 하나만 사용한다.
- `chatType``AI`, `DM` 문자열만 사용한다.
- 탭 전환 후 표시할 item이 없으면 목록을 비운다. 별도 empty placeholder는 추가하지 않는다.
### Chat Room List
API 응답의 채팅방 목록을 Figma의 `ChatList` 형태로 표시한다.
#### Requirements
- `RecyclerView` 기반 세로 목록으로 구성한다.
- 각 item은 프로필 이미지, 크리에이터/상대 이름, Direct badge, 마지막 메시지, 마지막 메시지 시간을 표시한다.
- 프로필 이미지는 원형 `58dp` 수준으로 표시하고 기존 이미지 로딩 정책을 따른다.
- 이름은 `18sp` bold, 마지막 메시지는 `16sp` medium, 시간은 `14sp` medium 기준으로 맞춘다.
- 마지막 메시지는 한 줄 말줄임 처리한다.
- `chatType`이 DM이면 Figma의 `Direct` badge를 표시하고, AI 채팅이면 표시하지 않는다.
- unread dot은 표시하지 않는다.
- 채팅방 item 터치 시 extras는 `roomId`만 전달한다.
- `chatType``AI`이면 기존 `ChatRoomActivity`로 이동한다.
- `chatType``DM`이면 이번 범위에서는 이동을 구현하지 않는다. DM 페이지 생성 및 연결은 다음 범위에서 진행한다.
#### Edge Cases
- `targetImageUrl`이 비어 있거나 로딩 실패하면 기존 placeholder 정책을 따른다.
- `targetName`이 비어 있으면 빈 문자열 그대로 표시하고 별도 대체 문구는 추가하지 않는다.
- `lastMessage`가 비어 있으면 빈 줄이 아닌 한 줄 영역을 유지한다.
- `lastMessageAt`은 ISO-8601 문자열이다.
- `lastMessageAt`은 디바이스에 설정된 Timezone으로 변환한 뒤 화면 표시 문자열로 변환한다.
- 일주일까지는 상대 시간으로 표시한다. 이 문구는 다국어 처리가 필요하다.
- 상대 시간으로 표시하지 않는 날짜는 언어별 날짜 포맷으로 표시한다.
- 일주일보다 오래되었고 올해 받은 메시지라면 아래 언어별 날짜 포맷을 적용한다.
- 한국어는 `M월 d일` 형태로 표시한다.
- 영어는 `MMM d` 형태로 표시한다.
- 일본어는 `M月d日` 형태로 표시한다.
- 올해가 2026년이고 2025년에 받은 메시지처럼 다른 연도 메시지라면 `yyyy.MM.dd` 형태로 표시한다.
### Floating Action Button
새 채팅 또는 대화 시작 진입점을 표시한다.
#### Requirements
- Figma 기준 `soda/400` 계열 원형 버튼과 plus icon을 사용한다.
- 기존 `btn_plus_round`, `ic_plus_no_bg`, `btn_add` 등 사용 가능한 리소스를 먼저 확인해 재사용한다.
- 적합한 기존 리소스가 없으면 `v2` 전용 drawable을 최소 신규 추가한다.
- 버튼 클릭 동작은 추후 결정한다.
#### Edge Cases
- 클릭 대상 화면이 확정되지 않았으므로 버튼은 표시하되 클릭 동작은 추가하지 않고 명시적 주석/계획 항목으로 남긴다.
### API Integration
채팅방 목록 API를 호출하고 UI state로 변환한다.
#### API Endpoint
- `GET /api/v2/chat/rooms`
#### Request Parameters
- `filter`: `ALL`, `AI`, `DM` 중 하나를 전달한다.
- `cursor`: 다음 페이지 요청 시 전달한다. 첫 페이지 요청 시에는 전달하지 않거나 서버 계약에 맞는 empty/null 값을 사용한다.
#### Response Model
현재 파일은 `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/HomeChatModels.kt`이며, 기존 data class는 아래 이름을 사용한다.
```kotlin
data class ChatRoomListPageResponse(
val rooms: List<ChatRoomListItemResponse>,
val hasMore: Boolean,
val nextCursor: String?
)
data class ChatRoomListItemResponse(
val roomId: Long,
val chatType: String,
val targetName: String,
val targetImageUrl: String,
val lastMessage: String,
val lastMessageAt: String
)
```
#### Naming Requirements
- `HomeChatModels.kt`는 채팅 탭 스펙과 맞지 않으므로 `ChatRoomModels.kt` 또는 동등하게 명확한 이름으로 변경하는 것을 권장한다.
- DTO class 이름은 현재 `ChatRoomListPageResponse`, `ChatRoomListItemResponse`가 스펙에 적합하므로 유지 가능하다.
- API 응답 최상위가 실제로 `rooms`, `hasMore`, `nextCursor` 구조인지 구현 전 백엔드 계약 또는 샘플 응답으로 최종 확인한다.
#### Data Flow
- `ChatRoomApi` -> `ChatRoomRepository` -> `ChatMainViewModel` -> `ChatMainFragment` 흐름을 사용한다.
- Retrofit 응답은 기존 패턴처럼 `Single<ApiResponse<ChatRoomListPageResponse>>`를 우선 사용한다.
- token 전달은 기존 v2 홈 API 패턴과 `SharedPreferenceManager.token` 사용 방식을 따른다.
- UI model은 DTO와 분리해 `ChatRoomListUiItem` 같은 이름으로 둔다.
- 첫 페이지 요청은 기존 목록을 clear한 뒤 응답 목록을 새로 세팅한다.
- `hasMore=true``nextCursor`가 제공되면 스크롤 pagination으로 다음 페이지 API를 호출한다.
- 다음 페이지 응답은 기존 목록 뒤에 append한다.
#### Edge Cases
- API success가 false이거나 data가 null이면 목록을 비우고 기존 Toast/error 정책을 따른다.
- 네트워크 오류가 발생해도 crash 없이 처리한다.
- `hasMore=false`이거나 `nextCursor`가 제공되지 않으면 추가 페이지를 호출하지 않는다.
---
## 8. UX / UI Expectations
- Figma 기준 화면 폭 `402`의 좌우 여백 감각을 Android `match_parent` 환경에서 유지한다.
- 필터 탭은 높이 `52dp`, 좌측 여백은 기존 `CapsuleTabBarView``20dp` 또는 Figma `14dp` 중 구현 계획에서 기존 위젯 일관성을 우선해 결정한다.
- 채팅방 item은 세로 높이 약 `86dp`, 내부 padding `14dp`, 프로필과 텍스트 사이 간격 `14dp`를 기준으로 한다.
- Direct badge는 `soda/400` 배경, radius `4dp`, Pattaya font 기반 텍스트를 우선 적용한다.
- unread dot은 표시하지 않는다.
- 긴 이름과 긴 마지막 메시지는 겹치지 않고 말줄임 처리되어야 한다.
- 하단 내비게이션, 미니 플레이어, 플로팅 버튼이 서로 겹치지 않아야 한다.
---
## 9. Technical Constraints
- Android XML Views, ViewBinding, RecyclerView, RxJava3, Retrofit, Gson, Koin 구조를 따른다.
- 신규 `ViewModel`, API, Repository, adapter, custom view는 `kr.co.vividnext.sodalive.v2.main.chat` 하위에 작성한다.
- 재사용 가능한 전용 위젯이 필요하면 `kr.co.vividnext.sodalive.v2.widget` 하위에 작성하되, 단일 화면 전용이면 `v2.main.chat.ui` 하위에 둔다.
- 기존 `BaseFragment<FragmentV2MainChatBinding>` 구조를 유지한다.
- 구현 전 `docs/20260609_채팅_탭_페이지/plan-task.md`를 작성하고, 그 문서에 따라 최소 구현한다.
- 테스트는 mapper/formatter 단위 테스트와 Fragment layout/adapter 검증을 우선한다.
---
## 10. Widget Classification
| Figma 요소 | 기존 재사용 후보 | 판정 | 비고 |
| --- | --- | --- | --- |
| 하단 메인 nav | `MainV2Activity` + `BottomNavigationView` + `menu_main_v2_bottom_navigation` | 재사용 | Fragment 내부 구현 제외 |
| 타이틀바 우측 cash/search icon | `ic_bar_cash`, `ic_bar_search` | 재사용 | `view_title_bar_default`의 우측 액션 영역을 가변 아이콘 구조로 확장해 재사용한다 |
| 타이틀바 제목 `대화` | `view_title_bar_default` | 재사용 + 최소 확장 | 신규 chat 전용 title layout은 만들지 않는다 |
| 필터 탭 `전체/AI 채팅/DM` | `CapsuleTabBarView` | 재사용 + 최소 확장 가능 | selected 텍스트 색상 검정 처리 필요 가능성 |
| 채팅방 리스트 item | 없음 | 신규 필요 | `ChatRoomListItemView` 또는 `RecyclerView` item XML/Adapter |
| Direct badge | 없음 | 신규 필요 | 작은 badge View/drawable, Pattaya font 재사용 |
| 프로필 원형 이미지 | 기존 Coil 로딩 패턴, drawable placeholder | 재사용 | 전용 위젯은 신규 item 내부에서 처리 |
| unread dot | 해당 없음 | 구현 제외 | 이번 PRD에서는 unread dot을 표시하지 않는다 |
| 플로팅 plus 버튼 | `btn_plus_round`, `ic_plus_no_bg`, `btn_add` 후보 | 부분 재사용/확인 필요 | 크기/색상이 맞지 않으면 신규 drawable 최소 추가 |
---
## 11. Metrics
- `ChatMainFragment` 진입 시 `GET /api/v2/chat/rooms`가 한 번 호출된다.
- API 응답의 `rooms``RecyclerView` item으로 표시된다.
- `전체`, `AI 채팅`, `DM` 탭 선택 시 각각 `ALL`, `AI`, `DM` filter로 첫 페이지 API가 호출된다.
- 첫 페이지 로딩 시 기존 목록이 clear되고 새 응답 목록이 표시된다.
- `hasMore=true``nextCursor`가 제공되면 스크롤 pagination이 동작한다.
- DM item에만 `Direct` badge가 표시된다.
- 마지막 메시지는 한 줄 말줄임 처리된다.
- unread dot은 표시되지 않는다.
- `lastMessageAt`은 ISO-8601 문자열을 디바이스 Timezone 기준으로 변환한 뒤 일주일까지는 상대 시간으로 표시한다.
- 상대 시간으로 표시하지 않는 올해 메시지의 날짜 포맷은 한국어 `M월 d일`, 영어 `MMM d`, 일본어 `M月d日`로 표시된다.
- 상대 시간으로 표시하지 않는 다른 연도 메시지는 `yyyy.MM.dd`로 표시된다.
- 채팅방 item 터치 시 `chatType=AI``ChatRoomActivity`로 이동하며 extras는 `roomId`만 전달한다.
- `chatType=DM` item 클릭 시 이동할 DM 페이지 생성 및 연결은 다음 범위에서 진행한다.
- 플로팅 버튼 클릭 동작은 추후 결정 사항으로 남긴다.
- 빈 응답, API 실패, 이미지 실패가 crash 없이 처리된다.
- 하단 메인 내비게이션은 기존 `MainV2Activity` 구조를 그대로 사용한다.
- mapper/formatter 단위 테스트와 관련 layout/adapter 테스트가 통과한다.
---
## 12. Open Questions
- 플로팅 plus 버튼 클릭 시 이동해야 하는 기존 화면 또는 신규 flow는 추후 결정한다.
- `chatType=DM` item 클릭 시 이동할 신규 DM 페이지의 Activity/Fragment 이름과 생성 범위는 다음 범위에서 확정한다.
- 상대 시간 표시의 다국어 string resource 문구는 구현 계획에서 확정한다.
---
## 13. References
- 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=177-3466&m=dev
- 대상 Fragment: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/ChatMainFragment.kt`
- 현재 layout: `app/src/main/res/layout/fragment_v2_main_chat.xml`
- 현재 DTO: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/chat/data/HomeChatModels.kt`
- 기존 필터 탭 위젯: `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt`
- 하단 내비게이션: `app/src/main/java/kr/co/vividnext/sodalive/v2/main/MainV2Activity.kt`
---
## 14. Verification Log
- 2026-06-09: `docs/prd/sample-prd.md`, `docs/agent-guides/work-plan-docs.md`, `docs/agent-guides/code-style.md`를 확인해 PRD 구조와 저장소 문서 규칙을 확인했다.
- 2026-06-09: Figma `177:3466`의 design context와 screenshot을 확인해 `title-bar`, `tab-bar`, `ChatList`, `button-floating`, `nav` 구조를 분석했다.
- 2026-06-09: `ChatMainFragment.kt`, `fragment_v2_main_chat.xml`, `HomeChatModels.kt`, `CapsuleTabBarView.kt`, `view_capsule_tab_bar.xml`, `activity_main_v2.xml`, `MainV2Activity.kt`, `view_title_bar_home.xml`을 확인해 재사용/신규 위젯 후보를 분류했다.
- 2026-06-09: 이번 단계는 PRD 작성만 수행했으며 구현/빌드/테스트는 실행하지 않았다.
- 2026-06-09: 사용자 추가 요구사항을 반영해 unread dot 미표시, `chatType`/API filter/cursor, `lastMessageAt` 표시 규칙, 탭 필터 재호출, pagination, 클릭 액션 범위를 보강했다.
- 2026-06-09: `chatType=DM` item 클릭 이동은 다음 범위로 분리하고, 상대 시간으로 표시하지 않는 올해 메시지의 언어별 날짜 포맷을 한국어 `M월 d일`, 영어 `MMM d`, 일본어 `M月d日`로 보강했다.
- 2026-06-09: 사용자 피드백에 따라 채팅 전용 `view_title_bar_chat.xml` 신규 생성을 제외하고, 기존 `view_title_bar_default.xml`의 우측 아이콘 영역을 가변 개수로 확장해 재사용하는 방향으로 위젯 분류를 갱신했다.