feat(main): 홈 대화 탭을 추가한다

This commit is contained in:
Yu Sung
2026-07-08 15:54:07 +09:00
parent 403eee262f
commit 09443761af
13 changed files with 938 additions and 23 deletions

View File

@@ -2091,6 +2091,27 @@
FE286EDC3151C5B7D62E5445 /* V2vApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613FD451377C66254773CF47 /* V2vApi.swift */; }; FE286EDC3151C5B7D62E5445 /* V2vApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 613FD451377C66254773CF47 /* V2vApi.swift */; };
FE3244627FFAD8667541CE01 /* ChannelDonationAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872F4F3506F36E0EAAE312ED /* ChannelDonationAllView.swift */; }; FE3244627FFAD8667541CE01 /* ChannelDonationAllView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 872F4F3506F36E0EAAE312ED /* ChannelDonationAllView.swift */; };
FE3C5D4BCD99FE5D5B389F03 /* GetCreatorDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A5D253179845EF7577A1E01 /* GetCreatorDetailResponse.swift */; }; FE3C5D4BCD99FE5D5B389F03 /* GetCreatorDetailResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A5D253179845EF7577A1E01 /* GetCreatorDetailResponse.swift */; };
C0A741000000000000000001 /* MainChatRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000001 /* MainChatRoomsResponse.swift */; };
C0A742000000000000000001 /* MainChatRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000001 /* MainChatRoomsResponse.swift */; };
C0A743000000000000000001 /* MainChatRoomsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000001 /* MainChatRoomsResponse.swift */; };
C0A741000000000000000002 /* MainChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000002 /* MainChatApi.swift */; };
C0A742000000000000000002 /* MainChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000002 /* MainChatApi.swift */; };
C0A743000000000000000002 /* MainChatApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000002 /* MainChatApi.swift */; };
C0A741000000000000000003 /* MainChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000003 /* MainChatRepository.swift */; };
C0A742000000000000000003 /* MainChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000003 /* MainChatRepository.swift */; };
C0A743000000000000000003 /* MainChatRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000003 /* MainChatRepository.swift */; };
C0A741000000000000000004 /* MainChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000004 /* MainChatViewModel.swift */; };
C0A742000000000000000004 /* MainChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000004 /* MainChatViewModel.swift */; };
C0A743000000000000000004 /* MainChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000004 /* MainChatViewModel.swift */; };
C0A741000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
C0A742000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
C0A743000000000000000005 /* MainChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000005 /* MainChatView.swift */; };
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
C0A742000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000006 /* MainChatRoomListItem.swift */; };
C0A741000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
C0A742000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
C0A743000000000000000007 /* MainChatFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A740000000000000000007 /* MainChatFilter.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@@ -3055,6 +3076,13 @@
F26CB443DA0ED4C45A18B258 /* PushNotificationApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationApi.swift; sourceTree = "<group>"; }; F26CB443DA0ED4C45A18B258 /* PushNotificationApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PushNotificationApi.swift; sourceTree = "<group>"; };
F6966E927CDB1DE31F936757 /* CreatorProfileGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorProfileGrid.swift; sourceTree = "<group>"; }; F6966E927CDB1DE31F936757 /* CreatorProfileGrid.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorProfileGrid.swift; sourceTree = "<group>"; };
F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultTitleBar.swift; sourceTree = "<group>"; }; F7F1421DBAB56E2F6891625E /* DefaultTitleBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DefaultTitleBar.swift; sourceTree = "<group>"; };
C0A740000000000000000001 /* MainChatRoomsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatRoomsResponse.swift; sourceTree = "<group>"; };
C0A740000000000000000002 /* MainChatApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatApi.swift; sourceTree = "<group>"; };
C0A740000000000000000003 /* MainChatRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatRepository.swift; sourceTree = "<group>"; };
C0A740000000000000000004 /* MainChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatViewModel.swift; sourceTree = "<group>"; };
C0A740000000000000000005 /* MainChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatView.swift; sourceTree = "<group>"; };
C0A740000000000000000006 /* MainChatRoomListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatRoomListItem.swift; sourceTree = "<group>"; };
C0A740000000000000000007 /* MainChatFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainChatFilter.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -5637,6 +5665,44 @@
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
C0A74000000000000000000100 /* Chat */ = {
isa = PBXGroup;
children = (
C0A74000000000000000000101 /* Models */,
C0A74000000000000000000102 /* Repository */,
C0A74000000000000000000103 /* Components */,
C0A740000000000000000004 /* MainChatViewModel.swift */,
C0A740000000000000000005 /* MainChatView.swift */,
C0A740000000000000000007 /* MainChatFilter.swift */,
);
path = Chat;
sourceTree = "<group>";
};
C0A74000000000000000000101 /* Models */ = {
isa = PBXGroup;
children = (
C0A740000000000000000001 /* MainChatRoomsResponse.swift */,
);
path = Models;
sourceTree = "<group>";
};
C0A74000000000000000000102 /* Repository */ = {
isa = PBXGroup;
children = (
C0A740000000000000000002 /* MainChatApi.swift */,
C0A740000000000000000003 /* MainChatRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
C0A74000000000000000000103 /* Components */ = {
isa = PBXGroup;
children = (
C0A740000000000000000006 /* MainChatRoomListItem.swift */,
);
path = Components;
sourceTree = "<group>";
};
C0A700000000000000000101 /* Content */ = { C0A700000000000000000101 /* Content */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -6104,27 +6170,6 @@
path = Community; path = Community;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
CC0800082FF0800000A5BBA3 /* Components */ = {
isa = PBXGroup;
children = (
CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */,
CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */,
CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */,
CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */,
);
path = Components;
sourceTree = "<group>";
};
CC0800092FF0800000A5BBA3 /* Detail */ = {
isa = PBXGroup;
children = (
CC0800082FF0800000A5BBA3 /* Components */,
CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */,
CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */,
);
path = Detail;
sourceTree = "<group>";
};
CC0700092FF0700000A5BBA3 /* Models */ = { CC0700092FF0700000A5BBA3 /* Models */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -6156,6 +6201,27 @@
path = ReplyDetail; path = ReplyDetail;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
CC0800082FF0800000A5BBA3 /* Components */ = {
isa = PBXGroup;
children = (
CC0800042FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailContentView.swift */,
CC0800052FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentListView.swift */,
CC0800062FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailCommentRow.swift */,
CC0800072FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailInputBar.swift */,
);
path = Components;
sourceTree = "<group>";
};
CC0800092FF0800000A5BBA3 /* Detail */ = {
isa = PBXGroup;
children = (
CC0800082FF0800000A5BBA3 /* Components */,
CC0800022FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailView.swift */,
CC0800032FF0800000A5BBA3 /* CreatorChannelCommunityPostDetailViewModel.swift */,
);
path = Detail;
sourceTree = "<group>";
};
D37878F5929361B43B1E3C5C /* Card */ = { D37878F5929361B43B1E3C5C /* Card */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -6179,6 +6245,7 @@
children = ( children = (
C0A700000000000000000101 /* Content */, C0A700000000000000000101 /* Content */,
1BA796BAC773D01D80F8F9CE /* Home */, 1BA796BAC773D01D80F8F9CE /* Home */,
C0A74000000000000000000100 /* Chat */,
7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */, 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */,
0EC05859619BDC03056911E7 /* MainTab.swift */, 0EC05859619BDC03056911E7 /* MainTab.swift */,
B8F49E8BB9BACFCA8878B70D /* MainTabBarButton.swift */, B8F49E8BB9BACFCA8878B70D /* MainTabBarButton.swift */,
@@ -6700,6 +6767,13 @@
C0A711000000000000000004 /* MainContentAllViewModel.swift in Sources */, C0A711000000000000000004 /* MainContentAllViewModel.swift in Sources */,
C0A711000000000000000005 /* MainContentAllView.swift in Sources */, C0A711000000000000000005 /* MainContentAllView.swift in Sources */,
C0A711000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */, C0A711000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */,
C0A741000000000000000001 /* MainChatRoomsResponse.swift in Sources */,
C0A741000000000000000002 /* MainChatApi.swift in Sources */,
C0A741000000000000000003 /* MainChatRepository.swift in Sources */,
C0A741000000000000000004 /* MainChatViewModel.swift in Sources */,
C0A741000000000000000005 /* MainChatView.swift in Sources */,
C0A741000000000000000006 /* MainChatRoomListItem.swift in Sources */,
C0A741000000000000000007 /* MainChatFilter.swift in Sources */,
C0A701000000000000000001 /* MainContentView.swift in Sources */, C0A701000000000000000001 /* MainContentView.swift in Sources */,
C0A701000000000000000002 /* MainContentTab.swift in Sources */, C0A701000000000000000002 /* MainContentTab.swift in Sources */,
C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */, C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */,
@@ -7595,6 +7669,13 @@
C0A712000000000000000004 /* MainContentAllViewModel.swift in Sources */, C0A712000000000000000004 /* MainContentAllViewModel.swift in Sources */,
C0A712000000000000000005 /* MainContentAllView.swift in Sources */, C0A712000000000000000005 /* MainContentAllView.swift in Sources */,
C0A712000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */, C0A712000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */,
C0A742000000000000000001 /* MainChatRoomsResponse.swift in Sources */,
C0A742000000000000000002 /* MainChatApi.swift in Sources */,
C0A742000000000000000003 /* MainChatRepository.swift in Sources */,
C0A742000000000000000004 /* MainChatViewModel.swift in Sources */,
C0A742000000000000000005 /* MainChatView.swift in Sources */,
C0A742000000000000000006 /* MainChatRoomListItem.swift in Sources */,
C0A742000000000000000007 /* MainChatFilter.swift in Sources */,
C0A702000000000000000001 /* MainContentView.swift in Sources */, C0A702000000000000000001 /* MainContentView.swift in Sources */,
C0A702000000000000000002 /* MainContentTab.swift in Sources */, C0A702000000000000000002 /* MainContentTab.swift in Sources */,
C0A702000000000000000003 /* MainContentRecommendationView.swift in Sources */, C0A702000000000000000003 /* MainContentRecommendationView.swift in Sources */,
@@ -8530,6 +8611,13 @@
C0A713000000000000000004 /* MainContentAllViewModel.swift in Sources */, C0A713000000000000000004 /* MainContentAllViewModel.swift in Sources */,
C0A713000000000000000005 /* MainContentAllView.swift in Sources */, C0A713000000000000000005 /* MainContentAllView.swift in Sources */,
C0A713000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */, C0A713000000000000000006 /* MainContentSeriesThumbnailCard.swift in Sources */,
C0A743000000000000000001 /* MainChatRoomsResponse.swift in Sources */,
C0A743000000000000000002 /* MainChatApi.swift in Sources */,
C0A743000000000000000003 /* MainChatRepository.swift in Sources */,
C0A743000000000000000004 /* MainChatViewModel.swift in Sources */,
C0A743000000000000000005 /* MainChatView.swift in Sources */,
C0A743000000000000000006 /* MainChatRoomListItem.swift in Sources */,
C0A743000000000000000007 /* MainChatFilter.swift in Sources */,
C0A703000000000000000001 /* MainContentView.swift in Sources */, C0A703000000000000000001 /* MainContentView.swift in Sources */,
C0A703000000000000000002 /* MainContentTab.swift in Sources */, C0A703000000000000000002 /* MainContentTab.swift in Sources */,
C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */, C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */,

View File

@@ -48,6 +48,44 @@ enum DateParser {
return I18n.Time.daysAgo(max(1, Int(interval / 86_400))) return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
} }
static func chatListDateText(fromUTC text: String, fallback: String, now: Date = Date()) -> String {
guard let date = parse(text) else {
return fallback
}
let interval = max(0, now.timeIntervalSince(date))
if interval < 60 {
return I18n.Time.justNow
}
if interval < 3600 {
return I18n.Time.minutesAgo(max(1, Int(interval / 60)))
}
if interval < 86_400 {
return I18n.Time.hoursAgo(max(1, Int(interval / 3600)))
}
if interval < 604_800 {
return I18n.Time.daysAgo(max(1, Int(interval / 86_400)))
}
let calendar = Calendar.current
if calendar.component(.year, from: date) == calendar.component(.year, from: now) {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: LanguageHeaderProvider.current)
formatter.timeZone = calendar.timeZone
formatter.dateFormat = I18n.MainChat.monthDayDateFormat
return formatter.string(from: date)
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = calendar.timeZone
formatter.dateFormat = "yyyy.MM.dd"
return formatter.string(from: date)
}
// : ISO8601( ) ISO8601 RFC3339 // : ISO8601( ) ISO8601 RFC3339
private static let parsers: [(String) -> Date?] = [ private static let parsers: [(String) -> Date?] = [
{ ISO8601.fractional.date(from: $0) }, { ISO8601.fractional.date(from: $0) },

View File

@@ -2952,6 +2952,44 @@ If you block this user, the following features will be restricted.
} }
} }
enum MainChat {
static var filterAll: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var filterAI: String {
pick(ko: "AI 채팅", en: "AI chats", ja: "AIチャット")
}
static var filterDM: String {
pick(ko: "DM", en: "DM", ja: "DM")
}
static var emptyStateMessage: String {
pick(
ko: "대화 목록이 없습니다.",
en: "No talks yet.",
ja: "トークはまだありません。"
)
}
static var loadFailedMessage: String {
pick(
ko: "대화 목록을 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load talks.\nPlease try again later.",
ja: "トーク一覧を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
static var directTag: String {
pick(ko: "Direct", en: "Direct", ja: "Direct")
}
static var monthDayDateFormat: String {
pick(ko: "M월 d일", en: "MMM d", ja: "M月d日")
}
}
enum Main { enum Main {
enum EventPopup { enum EventPopup {
static var doNotShowAgain: String { static var doNotShowAgain: String {
@@ -2967,7 +3005,7 @@ If you block this user, the following features will be restricted.
static var home: String { pick(ko: "", en: "Home", ja: "ホーム") } static var home: String { pick(ko: "", en: "Home", ja: "ホーム") }
static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") } static var content: String { pick(ko: "콘텐츠", en: "Content", ja: "コンテンツ") }
static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") } static var live: String { pick(ko: "라이브", en: "Live", ja: "ライブ") }
static var chat: String { pick(ko: "채팅", en: "Chat", ja: "チャッ") } static var chat: String { pick(ko: "대화", en: "Talks", ja: "ーク") }
static var my: String { pick(ko: "마이", en: "My", ja: "マイ") } static var my: String { pick(ko: "마이", en: "My", ja: "マイ") }
} }

View File

@@ -0,0 +1,89 @@
import SwiftUI
struct MainChatRoomListItem: View {
let room: MainChatRoomItem
let onTap: (Int, String) -> Void
var body: some View {
Button {
onTap(room.roomId, room.chatType)
} label: {
HStack(alignment: .center, spacing: SodaSpacing.s14) {
DownsampledKFImage(url: URL(string: room.targetImageUrl), size: CGSize(width: 58, height: 58))
.clipShape(Circle())
VStack(alignment: .leading, spacing: SodaSpacing.s12) {
HStack(alignment: .center, spacing: SodaSpacing.s6) {
Text(room.targetName)
.appFont(size: 18, weight: .bold)
.foregroundColor(.white)
.lineLimit(1)
.truncationMode(.tail)
if room.isDirectMessage {
MainChatDirectTagView()
}
Spacer(minLength: SodaSpacing.s8)
Text(room.chatListDateText())
.appFont(size: 14, weight: .medium)
.foregroundColor(Color.gray500)
.lineLimit(1)
}
Text(room.lastMessage)
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(SodaSpacing.s14)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private struct MainChatDirectTagView: View {
var body: some View {
Text(I18n.MainChat.directTag)
.appFont(size: 12, weight: .bold)
.italic()
.foregroundColor(.white)
.padding(.horizontal, SodaSpacing.s4)
.padding(.vertical, SodaSpacing.s4)
.background(Color.soda400)
.cornerRadius(4)
}
}
private extension MainChatRoomItem {
var isDirectMessage: Bool {
chatType == "DM"
}
func chatListDateText(now: Date = Date()) -> String {
DateParser.chatListDateText(fromUTC: lastMessageAt, fallback: lastMessageAt, now: now)
}
}
struct MainChatRoomListItem_Previews: PreviewProvider {
static var previews: some View {
MainChatRoomListItem(
room: MainChatRoomItem(
roomId: 1,
chatType: "DM",
targetName: "크리에이터 이름",
targetImageUrl: "https://picsum.photos/500/500",
lastMessage: "마지막 대화 내용이 들어가는 부분입니다. 한 줄 넘어가는경우 말줄임",
lastMessageAt: "2026-07-01T01:30:00Z"
),
onTap: { _, _ in }
)
.background(Color.black)
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
enum MainChatFilter: CaseIterable, Hashable {
case all
case ai
case dm
var queryValue: String {
switch self {
case .all:
return "ALL"
case .ai:
return "AI"
case .dm:
return "DM"
}
}
var title: String {
switch self {
case .all:
return I18n.MainChat.filterAll
case .ai:
return I18n.MainChat.filterAI
case .dm:
return I18n.MainChat.filterDM
}
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct MainChatView: View {
let onTapCanCharge: () -> Void
let onTapSearch: () -> Void
@StateObject private var viewModel = MainChatViewModel()
var body: some View {
VStack(spacing: 0) {
DefaultTitleBar(title: MainTab.chat.title) {
HStack(spacing: SodaSpacing.s14) {
Image("ic_bar_cash")
.onTapGesture { onTapCanCharge() }
Image("ic_bar_search")
.onTapGesture { onTapSearch() }
}
}
CapsuleTabBar(
items: MainChatFilter.allCases,
selectedItem: selectedFilterBinding,
title: { $0.title }
)
chatListContent
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(Color.black.ignoresSafeArea())
.onAppear { viewModel.fetchFirstPageIfNeeded() }
.sodaToast(
isPresented: $viewModel.isShowPopup,
message: viewModel.errorMessage,
autohideIn: 2
)
}
private var selectedFilterBinding: Binding<MainChatFilter> {
Binding(
get: { viewModel.selectedFilter },
set: { viewModel.applyFilter($0) }
)
}
@ViewBuilder
private var chatListContent: some View {
if viewModel.isLoading && viewModel.rooms.isEmpty {
ProgressView()
.progressViewStyle(.circular)
.tint(Color.soda400)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.shouldShowEmptyState {
Text(I18n.MainChat.emptyStateMessage)
.appFont(size: 16, weight: .medium)
.foregroundColor(Color.gray500)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView(showsIndicators: false) {
LazyVStack(spacing: 0) {
ForEach(viewModel.rooms) { room in
MainChatRoomListItem(room: room) { roomId, chatType in
handleChatRoomTap(roomId: roomId, chatType: chatType)
}
.onAppear {
viewModel.fetchNextPageIfNeeded(currentItem: room)
}
}
if viewModel.isLoadingNextPage {
ProgressView()
.progressViewStyle(.circular)
.tint(Color.soda400)
.padding(.vertical, SodaSpacing.s20)
}
}
.padding(.top, 0)
}
}
}
private func handleChatRoomTap(roomId: Int, chatType: String) {
guard chatType == "AI" else { return }
AppState.shared.setAppStep(step: .chatRoom(id: roomId))
}
}
struct MainChatView_Previews: PreviewProvider {
static var previews: some View {
MainChatView(onTapCanCharge: {}, onTapSearch: {})
}
}

View File

@@ -0,0 +1,119 @@
import Foundation
import Combine
final class MainChatViewModel: ObservableObject {
private let repository = MainChatRepository()
private var subscription = Set<AnyCancellable>()
private var latestRequestId = 0
@Published var rooms = [MainChatRoomItem]()
@Published var selectedFilter: MainChatFilter = .all
@Published var isLoading = false
@Published var isLoadingNextPage = false
@Published var hasMore = false
@Published var nextCursor: String?
@Published var hasLoaded = false
@Published var errorMessage = ""
@Published var isShowPopup = false
var shouldShowEmptyState: Bool {
hasLoaded && isLoading == false && rooms.isEmpty
}
func fetchFirstPageIfNeeded() {
guard hasLoaded == false else { return }
fetchFirstPage(filter: selectedFilter)
}
func fetchFirstPage(filter: MainChatFilter) {
selectedFilter = filter
clearRooms()
fetchChatRooms(filter: filter, cursor: nil, isNextPage: false)
}
func applyFilter(_ filter: MainChatFilter) {
guard selectedFilter != filter else { return }
fetchFirstPage(filter: filter)
}
func fetchNextPageIfNeeded(currentItem: MainChatRoomItem) {
guard rooms.last?.roomId == currentItem.roomId else { return }
guard hasMore, let nextCursor, isLoading == false, isLoadingNextPage == false else { return }
fetchChatRooms(filter: selectedFilter, cursor: nextCursor, isNextPage: true)
}
private func fetchChatRooms(filter: MainChatFilter, cursor: String?, isNextPage: Bool) {
latestRequestId += 1
let requestId = latestRequestId
if isNextPage {
isLoadingNextPage = true
} else {
isLoading = true
}
repository.getChatRooms(filter: filter.queryValue, cursor: cursor)
.sink { [weak self] result in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.applyFailureState()
}
} receiveValue: { [weak self] response in
guard let self else { return }
guard requestId == self.latestRequestId else { return }
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<MainChatRoomsResponse>.self, from: response.data)
if let data = decoded.data, decoded.success {
self.applySuccessState(data, isNextPage: isNextPage)
} else {
self.errorMessage = decoded.message ?? I18n.MainChat.loadFailedMessage
self.isShowPopup = true
}
} catch {
ERROR_LOG(error.localizedDescription)
self.errorMessage = I18n.MainChat.loadFailedMessage
self.isShowPopup = true
}
self.hasLoaded = true
self.isLoading = false
self.isLoadingNextPage = false
}
.store(in: &subscription)
}
private func applySuccessState(_ data: MainChatRoomsResponse, isNextPage: Bool) {
hasMore = data.hasMore
nextCursor = data.nextCursor
if isNextPage {
rooms.append(contentsOf: data.rooms)
} else {
rooms = data.rooms
}
}
private func applyFailureState() {
errorMessage = I18n.MainChat.loadFailedMessage
isShowPopup = true
hasLoaded = true
isLoading = false
isLoadingNextPage = false
}
private func clearRooms() {
rooms = []
hasMore = false
nextCursor = nil
hasLoaded = false
}
}

View File

@@ -0,0 +1,18 @@
import Foundation
struct MainChatRoomsResponse: Decodable {
let rooms: [MainChatRoomItem]
let hasMore: Bool
let nextCursor: String?
}
struct MainChatRoomItem: Decodable, Identifiable {
let roomId: Int
let chatType: String
let targetName: String
let targetImageUrl: String
let lastMessage: String
let lastMessageAt: String
var id: Int { roomId }
}

View File

@@ -0,0 +1,45 @@
import Foundation
import Moya
enum MainChatApi {
case getChatRooms(filter: String, cursor: String?)
}
extension MainChatApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getChatRooms:
return "/api/v2/chat/rooms"
}
}
var method: Moya.Method {
switch self {
case .getChatRooms:
return .get
}
}
var task: Moya.Task {
switch self {
case .getChatRooms(let filter, let cursor):
var parameters: [String: Any] = [
"filter": filter
]
if let cursor {
parameters["cursor"] = cursor
}
return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString)
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
import Combine
import CombineMoya
import Moya
final class MainChatRepository {
private let api = MoyaProvider<MainChatApi>()
func getChatRooms(filter: String, cursor: String?) -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getChatRooms(filter: filter, cursor: cursor))
}
}

View File

@@ -182,7 +182,10 @@ struct MainView: View {
onTapNewAndHotAll: handleContentNewAndHotAllTap onTapNewAndHotAll: handleContentNewAndHotAllTap
) )
case .chat: case .chat:
MainPlaceholderTabView(title: MainTab.chat.title) MainChatView(
onTapCanCharge: handleHomeCanChargeTap,
onTapSearch: handleHomeSearchTap
)
case .my: case .my:
MyPageView() MyPageView()
} }

View File

@@ -0,0 +1,171 @@
# 계획/TASK: 홈 채팅(대화) 탭
관련 PRD: `docs/20260708_홈_채팅_탭/prd.md`
## 구현할 UI Figma
- 채팅 탭 전체(`177:3466`, `chat_001`): `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`
- 구성: `StatusBar`, `title-bar`(`289:2962`), `tab-bar`(`177:3468`, capsule), `chat-list` item(`177:3469`~`177:3473`), `nav`(`177:3476`)
- 제외: `button-floating`(`177:3474`)은 사용자 확인에 따라 이번 범위에서 추가하지 않는다.
## 재사용 위젯 vs 신규 위젯
- 재사용:
- `DefaultTitleBar`(`SodaLive/Sources/V2/Component/DefaultTitleBar.swift`)
- `CapsuleTabBar`(`SodaLive/Sources/V2/Component/CapsuleTabBar.swift`) — filter 탭
- `DownsampledKFImage` + `Circle` clip — 원형 프로필
- `I18n.Time`(`justNow`/`minutesAgo`/`hoursAgo`/`daysAgo`) — 7일 이내 상대 시간
- `ApiResponse<T>` 디코딩, Moya `TargetType` + `Repository` + `ViewModel`(Combine) 패턴
- 라우팅: `AppStep.chatRoom(id:)``ChatRoomView(roomId:)`(AI 채팅방), `AppStep.canCharge(refresh:)`(충전), `AppStep.search`(기존 검색)
- 신규:
- 채팅 탭 root View / ViewModel / API / Repository / Models
- filter enum(`MainChatFilter`: ALL/AI/DM)
- 채팅방 세로 리스트 아이템 컴포넌트, DM 태그(기존 `DirectTagView` 스타일 기반)
- 채팅 목록 전용 날짜 포맷 함수(7일 초과 시 `M월 d일` / `yyyy.MM.dd`, 다국어)
- I18n 문자열(탭 문구 변경, filter 문구, empty state)
---
### Phase 1: 다국어/문구
- [x] **Task 1.1: 탭/타이틀 문구 변경**
- 대상 파일:
- 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- `I18n.Main.Tab.chat``채팅/Chat/チャット``대화/Talks/トーク`로 변경한다.
- 검증 기준:
- 실행 명령: `rg "static var chat: String" SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: `대화` / `Talks` / `トーク`로 변경된 문구가 확인된다. `MainTab.chat.title``BottomTabView`에 동일 반영.
- [x] **Task 1.2: 채팅 탭 전용 I18n 추가**
- 대상 파일:
- 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용:
- `enum MainChat` 신설: filter 문구(`전체`/`AI 채팅`/`DM`), empty state 문구를 `pick(ko:en:ja:)`로 추가한다.
- 시간 포맷용 다국어 date format helper 추가(§Phase 4에서 사용): `M월 d일` / `MMM d` / `M月d日` 패턴.
- 검증 기준:
- 실행 명령: `rg "enum MainChat" SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: filter/empty/date-format 문구가 확인된다.
### Phase 2: 모델과 API 계층
- [x] **Task 2.1: 응답 모델 정의**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/Models/MainChatRoomsResponse.swift`
- 작업 내용:
- `MainChatRoomsResponse`(`rooms: [MainChatRoomItem]`, `hasMore: Bool`, `nextCursor: String?`) 정의.
- `MainChatRoomItem`(`roomId: Int`, `chatType: String`, `targetName: String`, `targetImageUrl: String`, `lastMessage: String`, `lastMessageAt: String`) 정의(`Decodable, Identifiable`, `id = roomId`).
- 검증 기준:
- 실행 명령: `rg "struct MainChatRoomsResponse|struct MainChatRoomItem|hasMore|nextCursor" SodaLive/Sources/V2/Main/Chat/Models`
- 기대 결과: 래퍼/아이템 모델과 pagination 필드가 확인된다.
- [x] **Task 2.2: filter enum 정의**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/MainChatFilter.swift`
- 작업 내용:
- `enum MainChatFilter: CaseIterable, Hashable { case all, ai, dm }` 정의.
- `queryValue`(`ALL`/`AI`/`DM`)와 `title`(I18n) 제공.
- 검증 기준:
- 실행 명령: `rg "enum MainChatFilter|queryValue|ALL|AI|DM" SodaLive/Sources/V2/Main/Chat/MainChatFilter.swift`
- 기대 결과: 3개 case와 query/title 매핑이 확인된다.
- [x] **Task 2.3: API/Repository 추가**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/Repository/MainChatApi.swift`
- 생성: `SodaLive/Sources/V2/Main/Chat/Repository/MainChatRepository.swift`
- 작업 내용:
- `MainChatApi.getChatRooms(filter: String, cursor: String?)` case, path `/api/v2/chat/rooms`, method `.get`.
- query: `filter` 항상 전달, `cursor`는 nil이 아닐 때만 전달.
- `MainChatRepository.getChatRooms(filter:cursor:)``AnyPublisher<Response, MoyaError>`.
- 검증 기준:
- 실행 명령: `rg "getChatRooms|/api/v2/chat/rooms|filter|cursor|URLEncoding.queryString" SodaLive/Sources/V2/Main/Chat/Repository`
- 기대 결과: endpoint/파라미터/publisher가 확인된다.
### Phase 3: ViewModel(cursor pagination + filter)
- [x] **Task 3.1: 목록 조회/filter/pagination 구현**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/MainChatViewModel.swift`
- 작업 내용:
- 상태: `rooms`, `selectedFilter`, `isLoading`, `isLoadingNextPage`, `hasMore`, `nextCursor`, `hasLoaded`, `errorMessage`, `isShowPopup`.
- `fetchFirstPage(filter:)`: `cursor` 없이 조회, 성공 시 `rooms` 교체.
- `applyFilter(_:)`: 선택 filter 변경 시 기존 목록 초기화 후 첫 페이지 조회(같은 filter 재선택은 무시).
- `fetchNextPageIfNeeded(currentItem:)`: 마지막 아이템 노출 & `hasMore` & `nextCursor` 존재 시 다음 페이지 조회 후 append.
- `latestRequestId` 경합 방지, `ApiResponse<MainChatRoomsResponse>` 디코딩.
- 검증 기준:
- 실행 명령: `rg "final class MainChatViewModel|fetchFirstPage|applyFilter|fetchNextPageIfNeeded|latestRequestId|hasMore|nextCursor" SodaLive/Sources/V2/Main/Chat/MainChatViewModel.swift`
- 기대 결과: 첫 페이지 교체/append와 경합 방지 로직이 확인된다.
### Phase 4: 시간 포맷 유틸
- [x] **Task 4.1: 채팅 목록 전용 날짜 포맷 함수 추가**
- 대상 파일:
- 수정: `SodaLive/Sources/Common/DateParser.swift`
- 작업 내용:
- 신규 static 함수(예: `chatListDateText(fromUTC:now:)`) 추가. 기존 `relativeTimeText`는 변경하지 않는다.
- 디바이스 timezone(`Calendar.current`) 기준으로:
- 7일 이내: `I18n.Time` 상대 시간.
- 7일 초과 & 올해: 다국어 `M월 d일`(`MMM d`/`M月d日`).
- 과거 연도: `yyyy.MM.dd`.
- 파싱 실패 시 fallback 원문 반환.
- 검증 기준:
- 실행 명령: `rg "chatListDateText|M월 d일|yyyy.MM.dd" SodaLive/Sources/Common/DateParser.swift SodaLive/Sources/I18n/I18n.swift`
- 기대 결과: 3구간 분기와 다국어 포맷이 확인된다.
### Phase 5: 컴포넌트
- [x] **Task 5.1: 채팅방 리스트 아이템**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/Components/MainChatRoomListItem.swift`
- 작업 내용:
- 원형 프로필(`DownsampledKFImage`) + 이름(bold) + 마지막 메시지(1줄 tail) + 시간 텍스트(`DateParser.chatListDateText`) 레이아웃.
- `chatType == "DM"`이면 DM 태그 표시(soda400 배경, 흰색). unread dot 미표시.
- item tap closure로 `roomId`, `chatType` 전달.
- 검증 기준:
- 실행 명령: `rg "struct MainChatRoomListItem|DownsampledKFImage|chatListDateText|DM" SodaLive/Sources/V2/Main/Chat/Components/MainChatRoomListItem.swift`
- 기대 결과: 아이템 레이아웃과 DM 태그/시간 표시가 확인된다.
### Phase 6: root View 및 탭 연결
- [x] **Task 6.1: 채팅 탭 root View**
- 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Chat/MainChatView.swift`
- 작업 내용:
- `DefaultTitleBar(title: MainTab.chat.title)` + 우측 메뉴(`ic_bar_cash`, `ic_bar_search`) + `CapsuleTabBar`(filter) + 세로 목록(`ScrollView`/`LazyVStack`)을 구성한다.
- `ic_bar_cash` 터치 시 `onTapCanCharge`, `ic_bar_search` 터치 시 `onTapSearch` closure를 호출한다.
- `CapsuleTabBar` 선택 변경을 `viewModel.applyFilter(_:)`에 연결.
- 마지막 아이템 `onAppear`에서 `fetchNextPageIfNeeded(currentItem:)` 호출.
- 아이템 tap: `chatType == "AI"``AppState.shared.setAppStep(step: .chatRoom(id: roomId))`, `"DM"` → 다음 범위의 DM 화면 추가 전까지 미연결.
- 첫 진입 시 `fetchFirstPage(filter: .all)` 호출, empty state 처리.
- 플로팅 버튼은 추가하지 않는다.
- 검증 기준:
- 실행 명령: `rg "struct MainChatView|DefaultTitleBar|ic_bar_cash|ic_bar_search|CapsuleTabBar|applyFilter|fetchNextPageIfNeeded|chatRoom\\(id:" SodaLive/Sources/V2/Main/Chat/MainChatView.swift`
- 기대 결과: title bar 우측 메뉴/filter/목록/pagination/라우팅 연결이 확인되고 `MainChatFloatingButton`은 존재하지 않는다.
- [x] **Task 6.2: MainView 채팅 탭 교체**
- 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/MainView.swift`
- 작업 내용:
- `contentView``.chat` case를 `MainPlaceholderTabView(title: MainTab.chat.title)``MainChatView(...)`로 교체한다.
- 기존 `handleHomeCanChargeTap`, `handleHomeSearchTap``MainChatView`의 우측 메뉴 closure로 전달한다.
- 검증 기준:
- 실행 명령: `rg "case .chat:|MainChatView|handleHomeCanChargeTap|handleHomeSearchTap|MainPlaceholderTabView" SodaLive/Sources/V2/Main/MainView.swift`
- 기대 결과: `.chat`에서 `MainChatView`가 렌더되고 placeholder는 제거되며 충전/검색 라우팅 closure가 전달된다.
### Phase 7: 프로젝트 등록 및 검증
- [x] **Task 7.1: Xcode 프로젝트에 신규 파일 등록 및 빌드**
- 대상 파일:
- 수정: `SodaLive.xcodeproj/project.pbxproj`
- 작업 내용:
- 신규 `.swift` 파일을 타깃 소스에 추가한다.
- 검증 기준:
- 실행 명령: `docs/agent-guides/build-test-verification.md` 기준 빌드.
- 기대 결과: 빌드 성공, 채팅 탭에서 목록/필터/pagination/AI 라우팅 정상 동작.
---
## 검증 기록
- 2026-07-08 문서 작성: Figma `177:3466` design context/metadata 확인, 기존 재사용 위젯(`DefaultTitleBar`, `CapsuleTabBar`, `DownsampledKFImage`, `I18n.Time`, `AppStep.chatRoom`)과 신규 필요 항목(리스트 아이템/플로팅/날짜 포맷/API·Repository·ViewModel·Models)을 분류해 PRD와 본 계획 문서에 반영. 본 범위는 PRD와 계획 문서 작성까지이며 구현은 포함하지 않음.
- 2026-07-08 문서 보완: 사용자 확인 사항을 반영해 플로팅 버튼을 구현 범위에서 제외하고, title bar 우측 메뉴를 `ic_bar_cash`/`ic_bar_search` 순서와 충전/검색 라우팅으로 확정했다. `chatType == "DM"` 라우팅은 다음 범위의 DM 화면 추가 전까지 미연결로 유지하고, 탭 영어/일본어 문구를 `Talks`/`トーク`로 갱신했다. 실행 명령: `sed -n`으로 PRD/계획 문서와 `documentation-policy.md` 확인, `rg`로 기존 V2 title bar 메뉴 및 `AppStep.canCharge`/`AppStep.search` 관례 확인.
- 2026-07-08 구현 완료: `I18n.Main.Tab.chat``대화/Talks/トーク`으로 변경하고 `I18n.MainChat` 문자열, `DateParser.chatListDateText(fromUTC:fallback:now:)`, V2 채팅 탭의 Model/API/Repository/ViewModel/View/Row 컴포넌트를 추가했다. `.chat` 탭은 `MainChatView`로 교체했고, 신규 Swift 파일을 `SodaLive.xcodeproj/project.pbxproj`의 Chat 그룹 및 앱 소스 빌드 단계에 등록했다.
- 2026-07-08 검증 기록: `xcodebuild -workspace "SodaLive.xcworkspace" -list` 성공. 최초 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`는 신규 파일 참조가 루트 경로로 해석되어 실패했고, pbxproj 파일 참조 ID를 정정한 뒤 동일 명령 재실행 결과 `** BUILD SUCCEEDED **`를 확인했다. 빌드 로그에는 기존 프로젝트의 중복 Compile Sources 경고가 다수 표시되며, 신규 Chat 파일도 기존 등록 패턴과 동일하게 중복 경고에 포함된다. 추가로 `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``** BUILD SUCCEEDED **`를 확인했다.

View File

@@ -0,0 +1,172 @@
# PRD: 홈 채팅(대화) 탭
## 1. Overview
메인 하단 탭의 채팅 탭(`MainTab.chat`)에 진입했을 때 표시하는 V2 대화 목록 화면을 제작한다. 화면은 상단 `DefaultTitleBar`(좌측 화면명 `대화`), 필터용 Capsule Tab(`전체` / `AI 채팅` / `DM`), 채팅방 목록으로 구성한다.
목록 API는 `GET /api/v2/chat/rooms`를 사용하며 `filter`(ALL / AI / DM)와 `cursor` 파라미터로 조회한다. 응답의 `hasMore``nextCursor`로 스크롤 pagination을 처리한다.
Figma 참조:
- 채팅 탭 전체: `177:3466`(`chat_001`), `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`
---
## 2. Problem
- 현재 채팅 탭은 `MainView``contentView`에서 `MainPlaceholderTabView(title: MainTab.chat.title)`로만 연결되어 실제 대화 목록이 없다.
- 홈 팔로잉 탭의 최근 대화(`MainHomeFollowingChatSection`)는 가로 스크롤 카드 형태라 채팅 탭의 세로 목록 UI와 구조가 다르다.
- 채팅방 목록 조회 API(`GET /api/v2/chat/rooms`)와 filter/cursor 기반 pagination이 연결되어 있지 않다.
- 탭명과 title bar 좌측 문구가 현재 `채팅`으로 노출되어 기획의 `대화`와 다르다.
---
## 3. Goals
- 채팅 탭 진입 시 `GET /api/v2/chat/rooms?filter=ALL`(첫 페이지)를 호출해 대화 목록을 표시한다.
- 탭명과 title bar 좌측 화면명을 `대화`로 변경한다.
- title bar 우측 메뉴에 `ic_bar_cash`, `ic_bar_search`를 순서대로 배치하고 각각 충전 페이지, 기존 검색 페이지로 이동한다.
- Capsule Tab(`전체` / `AI 채팅` / `DM`)으로 filter를 전환하고, 미선택 탭 터치 시 화면의 기존 데이터를 모두 지운 뒤 해당 filter의 첫 페이지를 다시 조회해 표시한다.
- `hasMore == true`이고 `nextCursor`가 있으면 마지막 아이템 노출 시 다음 페이지를 조회해 append한다.
- 채팅방 아이템은 프로필 이미지, 크리에이터 이름, DM 태그(`chatType == "DM"`), 마지막 메시지 미리보기, 마지막 메시지 상대/절대 시간을 표시한다.
- `lastMessageAt`(ISO-8601, UTC)을 디바이스 timezone 기준으로 변환해 규칙에 맞는 시간 텍스트를 다국어로 표시한다.
- 채팅방 아이템 터치 시 `chatType`에 따라 이동한다. `AI`이면 `ChatRoomView`(`AppStep.chatRoom(id:)`)로 이동하고, `DM`이면 이번 범위에서는 이동 대상 화면이 없으므로 라우팅을 연결하지 않는다.
---
## 4. Non-Goals
- unread dot(안 읽음 표시)은 이번 범위에서 표시하지 않는다.
- 플로팅 버튼은 이번 범위에서 추가하지 않는다.
- `chatType == "DM"` 아이템 터치 시 이동할 신규 DM 화면은 이번 범위를 벗어나며 다음 범위에서 만든다.
- 채팅방 생성/삭제/나가기 등 목록 조회 외 mutation은 다루지 않는다.
- 기존 홈 팔로잉 `MainHomeFollowingChatSection`(가로 카드)은 변경하지 않는다.
- Figma localhost asset URL을 앱 코드에 직접 사용하지 않는다.
- `Pods/**`, `generated/**`, `build/**`는 수정하지 않는다.
---
## 5. Target Users
- AI 캐릭터 또는 크리에이터와의 대화 목록을 한 곳에서 확인하려는 사용자
- filter로 AI 채팅 / DM만 골라 보려는 사용자
---
## 6. User Stories
- 사용자는 채팅 탭에 들어가 자신의 대화 목록을 최신 순으로 확인할 수 있다.
- 사용자는 `AI 채팅` 탭을 눌러 AI 대화만, `DM` 탭을 눌러 DM 대화만 볼 수 있다.
- 사용자는 목록을 아래로 스크롤하면 다음 페이지가 이어서 로드되는 것을 확인할 수 있다.
- 사용자는 각 대화의 마지막 메시지와 마지막 메시지 시간을 확인할 수 있다.
- 사용자는 AI 대화 아이템을 눌러 해당 AI 채팅방으로 이동할 수 있다.
---
## 7. Core Requirements
### 7.1 API
신규 V2 조회 API는 채팅 탭 전용 API/Repository로 추가한다. 응답 래퍼는 기존 V2 관례대로 `ApiResponse<...>`로 디코딩하고, Kotlin `Long`은 Swift `Int`로 선언한다.
목록:
- Method: `GET`
- Path: `/api/v2/chat/rooms`
- Query parameters:
- `filter`: `ALL` / `AI` / `DM` 중 하나. 선택된 Capsule Tab에 대응한다.
- `cursor`: 다음 페이지 조회에 사용할 커서. 첫 페이지 조회 시에는 전달하지 않는다.
서버 응답(`HomeChatModels`)은 채팅 탭 스펙에 맞춰 아래 이름으로 매핑한다. 서버 필드명은 유지하되 Swift 모델 이름만 목적에 맞게 정한다.
```kotlin
// 서버 응답 (참고: HomeChatModels)
data class ChatRoomsResponse(
val rooms: List<ChatRoomItem>,
@JsonProperty("hasMore")
val hasMore: Boolean,
val nextCursor: String?
)
data class ChatRoomItem(
val roomId: Long,
val chatType: String, // "AI" | "DM"
val targetName: String,
val targetImageUrl: String,
val lastMessage: String,
val lastMessageAt: String // ISO-8601 (UTC)
)
```
Swift 모델 네이밍(결정):
- 목록 래퍼: `MainChatRoomsResponse` (`rooms`, `hasMore`, `nextCursor`)
- 아이템: `MainChatRoomItem` (`roomId`, `chatType`, `targetName`, `targetImageUrl`, `lastMessage`, `lastMessageAt`)
> 참고: 홈 팔로잉의 기존 `ChatRoomListItemResponse`가 동일한 필드 구성을 가지지만, 페이지네이션 응답(`hasMore`/`nextCursor`)이 없고 다른 화면(`HomeFollowingTabResponse`) 전용이므로, 채팅 탭에서는 별도 `MainChatRoomItem` 모델을 둔다.
### 7.2 화면 구성
- root View는 `MainContentView` 패턴을 따른다: `DefaultTitleBar` + Capsule Tab + 목록 + `Color.black.ignoresSafeArea()`.
- title bar: `DefaultTitleBar(title: MainTab.chat.title)`를 사용하고, 좌측 화면명은 `대화`로 노출된다(§7.6 참고). 우측 메뉴는 `ic_bar_cash`, `ic_bar_search` 순서로 노출한다. `ic_bar_cash` 터치 시 충전 페이지(`AppStep.canCharge(refresh:)`), `ic_bar_search` 터치 시 기존 검색 페이지(`AppStep.search`)로 이동한다.
- filter Capsule Tab: 기존 `CapsuleTabBar<Item>`를 재사용하고 `전체` / `AI 채팅` / `DM` 3개를 노출한다.
- 목록: 세로 리스트(스크롤). 각 행은 신규 채팅방 리스트 아이템 컴포넌트로 표시한다.
### 7.3 채팅방 리스트 아이템
- 좌측: 원형 프로필 이미지(`DownsampledKFImage` + `Circle` clip 패턴 재사용).
- 우측 영역: 크리에이터 이름(bold), 마지막 메시지 미리보기(1줄, tail 말줄임), 마지막 메시지 시간 텍스트.
- `chatType == "DM"`이면 DM 태그를 함께 표시한다(기존 `DirectTagView` 스타일: `Color.soda400` 배경, 흰색 텍스트).
- unread dot은 표시하지 않는다.
### 7.4 filter 전환 동작
- 현재 선택되어 있지 않은 Capsule Tab을 터치하면 해당 filter로 첫 페이지 API를 호출한다.
- 첫 페이지 로딩 시 화면의 기존 목록 데이터를 모두 비우고, 응답으로 새로 세팅해 표시한다.
- 이미 선택된 탭을 다시 터치하면 재요청하지 않는다.
### 7.5 pagination
- 첫 페이지: `cursor` 없이 조회하고 결과로 목록을 교체한다.
- 다음 페이지: 마지막 아이템 노출 시 `hasMore == true`이고 `nextCursor`가 있으면 `cursor = nextCursor`로 조회하고 결과를 append한다.
- 중복 호출 방지를 위해 로딩 상태 guard와 요청 경합 방지(`latestRequestId` 패턴)를 둔다.
### 7.6 탭/타이틀 문구
- `I18n.Main.Tab.chat`의 한국어 값을 `채팅``대화`로 변경한다. 이 값은 하단 탭명과 title bar 좌측 화면명에 모두 사용된다.
- 영어/일본어 값은 `대화`(conversation) 의미에 맞춰 `Talks` / `トーク`로 변경한다.
- 이 문구는 V2 `MainTab`과 V1 `BottomTabView`에서 공유되므로 두 곳 모두 동일하게 `대화`로 노출된다.
### 7.7 시간 표시 규칙(다국어)
`lastMessageAt`(ISO-8601, UTC)을 디바이스 timezone으로 변환 후 아래 규칙으로 표시한다.
1. 일주일(7일) 이내: 상대 시간(다국어). 기존 `I18n.Time`(`justNow`/`minutesAgo`/`hoursAgo`/`daysAgo`)을 재사용한다.
2. 일주일 초과 & 올해 수신: `M월 d일` 형태(다국어).
- 한국어: `M월 d일`
- 영어: `MMM d`
- 일본어: `M月d日`
3. 올해가 아닌 과거 연도 수신: `yyyy.MM.dd` 형태.
> 기존 `DateParser.relativeTimeText`는 7일 초과 시 `개월/년 전`을 반환하므로 이 규칙과 다르다. 채팅 목록 전용 날짜 포맷 함수를 신규로 추가한다(기존 함수는 변경하지 않는다).
### 7.8 아이템 터치 라우팅
- `chatType == "AI"`: `AppState.shared.setAppStep(step: .chatRoom(id: roomId))``ChatRoomView(roomId:)`로 이동한다(extras는 `roomId`로 충분).
- `chatType == "DM"`: 이동할 화면이 다음 범위이므로 라우팅을 연결하지 않는다(액션 미연결).
### 7.9 Edge Cases
- 빈 목록: empty state 문구를 표시한다.
- 첫 페이지 로딩 중 filter 재전환: 이전 요청 결과가 화면에 반영되지 않도록 요청 경합을 방지한다.
- `nextCursor`가 없거나 `hasMore == false`: 추가 조회를 하지 않는다.
- `lastMessageAt` 파싱 실패: 원문 문자열을 fallback으로 표시한다(기존 `DateParser` 관례와 동일).
---
## 8. UX / UI Expectations
- 배경은 `Color.black`, 폰트/컬러/spacing은 기존 V2 토큰(`appFont`, `SodaSpacing`, `Color.soda400/gray900` 등)을 따른다.
- Capsule Tab 선택 시 `Color.soda400` 배경으로 강조(기존 `CapsuleTabBar` 동작 그대로).
- 목록 스크롤 시 하단 도달 전에 자연스럽게 다음 페이지가 이어지도록 한다.
---
## 9. Technical Constraints
- 신규 View/ViewModel/Repository/Model은 `SodaLive/Sources/V2/Main/Chat/**` 아래에 작성한다(콘텐츠 탭 `V2/Main/Content/**` 구조와 동일한 규칙).
- 여러 페이지 공용 컴포넌트는 `V2/Component/**`, 채팅 탭 전용 컴포넌트는 채팅 폴더 하위 `Components`에 배치한다.
- API는 Moya `TargetType` enum + `Repository`(`requestPublisher``Response`) + `ViewModel`(Combine, `ApiResponse<T>` 디코딩) 패턴을 따른다.
- 다국어 문자열은 `I18n``pick(ko:en:ja:)`로 추가한다.
---
## 10. Open Questions
- 현재 없음.
## 11. Confirmed Decisions (2026-07-08)
- 플로팅 버튼은 추가하지 않는다.
- title bar 우측 메뉴는 `ic_bar_cash`, `ic_bar_search` 순서로 노출한다.
- `ic_bar_cash`는 충전 페이지, `ic_bar_search`는 기존 검색 페이지로 이동한다.
- `chatType == "DM"` 터치 시 이동할 DM 화면은 다음 범위에서 추가한다.
- `I18n.Main.Tab.chat`의 영어/일본어 문구는 `Talks` / `トーク`로 확정한다.