From 86e18a1f7c2a01972f7a0b9fcf1c1daf46bd3944 Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 9 Jun 2026 23:13:59 +0900 Subject: [PATCH] =?UTF-8?q?docs(chat):=20=EC=B1=84=ED=8C=85=20=ED=83=AD=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=EA=B3=84=ED=9A=8D=EC=9D=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260609_채팅_탭_페이지/plan-task.md | 474 ++++++++++++++++++++++ docs/20260609_채팅_탭_페이지/prd.md | 259 ++++++++++++ 2 files changed, 733 insertions(+) create mode 100644 docs/20260609_채팅_탭_페이지/plan-task.md create mode 100644 docs/20260609_채팅_탭_페이지/prd.md diff --git a/docs/20260609_채팅_탭_페이지/plan-task.md b/docs/20260609_채팅_탭_페이지/plan-task.md new file mode 100644 index 00000000..bfdaee58 --- /dev/null +++ b/docs/20260609_채팅_탭_페이지/plan-task.md @@ -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>` + - Repository 계약: + - `fun getChatRooms(token: String, filter: String, cursor: String?): Single>` + - 검증 명령: `./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, val isAppending: Boolean = false)` + - `Empty` + - `Error(val message: String?)` + - `fun ChatRoomListItemResponse.toUiItem(context: Context): ChatRoomListUiItem?` + - `fun List.toUiItems(context: Context): List` + - 검증 명령: `./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` + - `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)` + - `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`만 상속하는 빈 구조이고, `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은 구조 확인/문서 갱신만 수행했으므로 빌드/테스트는 실행하지 않았다. diff --git a/docs/20260609_채팅_탭_페이지/prd.md b/docs/20260609_채팅_탭_페이지/prd.md new file mode 100644 index 00000000..b3930e21 --- /dev/null +++ b/docs/20260609_채팅_탭_페이지/prd.md @@ -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, + 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>`를 우선 사용한다. +- 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` 구조를 유지한다. +- 구현 전 `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`의 우측 아이콘 영역을 가변 개수로 확장해 재사용하는 방향으로 위젯 분류를 갱신했다.