feat(home): 팔로잉 탭 API 상태를 추가한다

This commit is contained in:
Yu Sung
2026-06-30 22:04:40 +09:00
parent e2d46614bd
commit ae09776c9b
9 changed files with 423 additions and 10 deletions

View File

@@ -246,6 +246,18 @@
401B3F072FEEF10000A5BBA3 /* MainHomeRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */; }; 401B3F072FEEF10000A5BBA3 /* MainHomeRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */; };
401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */; }; 401B3F082FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */; };
401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */; }; 401B3F092FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */; };
401C10112FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10012FEF300000A5BBA3 /* HomeFollowingTabResponse.swift */; };
401C10122FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10022FEF300000A5BBA3 /* MainHomeFollowingApi.swift */; };
401C10132FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */; };
401C10142FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */; };
401C10212FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10012FEF300000A5BBA3 /* HomeFollowingTabResponse.swift */; };
401C10222FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10022FEF300000A5BBA3 /* MainHomeFollowingApi.swift */; };
401C10232FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */; };
401C10242FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */; };
401C10312FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10012FEF300000A5BBA3 /* HomeFollowingTabResponse.swift */; };
401C10322FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10022FEF300000A5BBA3 /* MainHomeFollowingApi.swift */; };
401C10332FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */; };
401C10342FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */; };
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; }; 40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; }; 40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */; };
40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; }; 40235DF02E37983D00B09A87 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEF2E37983D00B09A87 /* PersistenceController.swift */; };
@@ -1780,6 +1792,10 @@
401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingApi.swift; sourceTree = "<group>"; }; 401B3F0B2FEEF10000A5BBA3 /* MainHomeRankingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingApi.swift; sourceTree = "<group>"; };
401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingRepository.swift; sourceTree = "<group>"; }; 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingRepository.swift; sourceTree = "<group>"; };
401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingViewModel.swift; sourceTree = "<group>"; }; 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingViewModel.swift; sourceTree = "<group>"; };
401C10012FEF300000A5BBA3 /* HomeFollowingTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFollowingTabResponse.swift; sourceTree = "<group>"; };
401C10022FEF300000A5BBA3 /* MainHomeFollowingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingApi.swift; sourceTree = "<group>"; };
401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingRepository.swift; sourceTree = "<group>"; };
401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingViewModel.swift; sourceTree = "<group>"; };
40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = "<group>"; }; 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = "<group>"; };
40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; }; 40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = "<group>"; };
40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = "<group>"; }; 40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = "<group>"; };
@@ -2779,6 +2795,9 @@
401A3FB92FEEAFA000A5BBA3 /* Following */ = { 401A3FB92FEEAFA000A5BBA3 /* Following */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
401C10052FEF300000A5BBA3 /* Models */,
401C10062FEF300000A5BBA3 /* Repository */,
401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */,
401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */, 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */,
); );
path = Following; path = Following;
@@ -2817,6 +2836,23 @@
path = Repository; path = Repository;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
401C10052FEF300000A5BBA3 /* Models */ = {
isa = PBXGroup;
children = (
401C10012FEF300000A5BBA3 /* HomeFollowingTabResponse.swift */,
);
path = Models;
sourceTree = "<group>";
};
401C10062FEF300000A5BBA3 /* Repository */ = {
isa = PBXGroup;
children = (
401C10022FEF300000A5BBA3 /* MainHomeFollowingApi.swift */,
401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */,
);
path = Repository;
sourceTree = "<group>";
};
40235DEA2E3797B000B09A87 /* Recent */ = { 40235DEA2E3797B000B09A87 /* Recent */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -5427,6 +5463,10 @@
401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, 401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
401C10112FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */,
401C10122FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */,
401C10132FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */,
401C10142FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */,
409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */, 409143302A85652F00EEBFB1 /* CanChargeView.swift in Sources */,
40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */, 40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */,
40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */, 40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */,
@@ -6187,6 +6227,10 @@
401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, 401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
401C10212FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */,
401C10222FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */,
401C10232FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */,
401C10242FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */,
B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */, B3AF650899B8FC84E0C0CDA0 /* MainPlaceholderTabView.swift in Sources */,
CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */, CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */,
A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */, A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */,
@@ -6977,6 +7021,10 @@
401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, 401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */,
401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */,
401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */,
401C10312FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */,
401C10322FEF300000A5BBA3 /* MainHomeFollowingApi.swift in Sources */,
401C10332FEF300000A5BBA3 /* MainHomeFollowingRepository.swift in Sources */,
401C10342FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift in Sources */,
ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */, ACD71056E97E13E48941ABBB /* MainPlaceholderTabView.swift in Sources */,
33BA3AB731849284E62AE04B /* MainTab.swift in Sources */, 33BA3AB731849284E62AE04B /* MainTab.swift in Sources */,
B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */, B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */,

View File

@@ -3042,6 +3042,64 @@ If you block this user, the following features will be restricted.
} }
} }
enum HomeFollowing {
static var emptyStateMessage: String {
pick(
ko: "팔로잉 소식이 아직 없어요.\n관심 있는 크리에이터를 팔로우해 보세요.",
en: "No following updates yet.\nFollow creators you care about.",
ja: "フォロー中の新着情報はまだありません。\n気になるクリエイターをフォローしてみましょう。"
)
}
static var loadFailedMessage: String {
pick(
ko: "팔로잉 정보를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.",
en: "Unable to load following updates.\nPlease try again later.",
ja: "フォロー情報を読み込めませんでした。\nしばらくしてからもう一度お試しください。"
)
}
static var loginRequiredMessage: String {
pick(
ko: "로그인 후 팔로잉 소식을 확인할 수 있어요.",
en: "Log in to see following updates.",
ja: "ログインするとフォロー中の新着情報を確認できます。"
)
}
static var loginButtonTitle: String {
pick(ko: "로그인", en: "Log in", ja: "ログイン")
}
static var allButtonTitle: String {
pick(ko: "전체", en: "All", ja: "すべて")
}
static var onAirTitle: String {
pick(ko: "On Air", en: "On Air", ja: "On Air")
}
static var recentChatTitle: String {
pick(ko: "최근 대화", en: "Recent chats", ja: "最近のトーク")
}
static var monthlyScheduleTitle: String {
pick(ko: "이달의 스케줄", en: "This month's schedule", ja: "今月のスケジュール")
}
static var recentNewsTitle: String {
pick(ko: "최근 소식", en: "Recent news", ja: "最近のニュース")
}
static var audioTag: String {
pick(ko: "오디오", en: "Audio", ja: "オーディオ")
}
static var photoTag: String {
pick(ko: "화보", en: "Photo", ja: "フォト")
}
}
enum HomeRanking { enum HomeRanking {
static var emptyStateMessage: String { static var emptyStateMessage: String {
pick( pick(

View File

@@ -0,0 +1,73 @@
import Foundation
import Combine
final class MainHomeFollowingViewModel: ObservableObject {
private let repository = MainHomeFollowingRepository()
private var subscription = Set<AnyCancellable>()
@Published var isLoading = false
@Published var response: HomeFollowingTabResponse?
@Published var isLoginRequired = false
@Published var message = ""
@Published var hasLoaded = false
func fetchFollowing() {
isLoading = true
message = ""
repository.getFollowing()
.sink { [weak self] result in
guard let self else { return }
switch result {
case .finished:
DEBUG_LOG("finish")
case .failure(let error):
ERROR_LOG(error.localizedDescription)
self.response = nil
self.isLoginRequired = false
self.message = I18n.HomeFollowing.loadFailedMessage
self.hasLoaded = true
self.isLoading = false
}
} receiveValue: { [weak self] response in
guard let self else { return }
let responseData = response.data
do {
let jsonDecoder = JSONDecoder()
let decoded = try jsonDecoder.decode(ApiResponse<HomeFollowingTabResponse>.self, from: responseData)
if let data = decoded.data, decoded.success {
self.isLoginRequired = data.isLoginRequired
self.response = data.isLoginRequired ? nil : data
self.message = data.isLoginRequired || data.hasContent ? "" : I18n.HomeFollowing.emptyStateMessage
} else {
self.response = nil
self.isLoginRequired = false
self.message = I18n.HomeFollowing.loadFailedMessage
}
} catch {
ERROR_LOG(error.localizedDescription)
self.response = nil
self.isLoginRequired = false
self.message = I18n.HomeFollowing.loadFailedMessage
}
self.hasLoaded = true
self.isLoading = false
}
.store(in: &subscription)
}
}
private extension HomeFollowingTabResponse {
var hasContent: Bool {
!followingCreators.isEmpty ||
!onAirLives.isEmpty ||
!recentChats.isEmpty ||
!monthlySchedules.isEmpty ||
!recentNews.isEmpty
}
}

View File

@@ -0,0 +1,154 @@
import Foundation
struct HomeFollowingTabResponse: Decodable {
let isLoginRequired: Bool
let followingCreators: [FollowingCreatorResponse]
let onAirLives: [FollowingLiveResponse]
let recentChats: [ChatRoomListItemResponse]
let monthlySchedules: [FollowingScheduleResponse]
let recentNews: [FollowingNewsResponse]
}
struct FollowingCreatorResponse: Decodable, Identifiable {
let creatorId: Int
let creatorNickname: String
let creatorProfileImageUrl: String
var id: Int { creatorId }
}
struct FollowingLiveResponse: Decodable, Identifiable {
let liveId: Int
let creatorProfileImageUrl: String
let creatorNickname: String
let title: String
let startedAtUtc: String
var id: Int { liveId }
}
struct ChatRoomListItemResponse: Decodable, Identifiable {
let roomId: Int
let chatType: String
let targetName: String
let targetImageUrl: String
let lastMessage: String
let lastMessageAt: String
var id: Int { roomId }
}
struct FollowingScheduleResponse: Decodable, Identifiable {
let scheduleId: String
let creatorId: Int
let creatorProfileImageUrl: String
let creatorNickname: String
let title: String
let type: CreatorActivityType
let targetId: Int
let scheduledAtUtc: String
let isOnAir: Bool
var id: String { scheduleId }
}
enum CreatorActivityType: Decodable, Hashable {
case live
case audio
case community
case liveReplay
case unknown(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let code = try container.decode(String.self)
switch code {
case "LIVE":
self = .live
case "AUDIO":
self = .audio
case "COMMUNITY":
self = .community
case "LIVE_REPLAY":
self = .liveReplay
default:
self = .unknown(code)
}
}
}
struct FollowingNewsResponse: Decodable, Identifiable {
let newsId: String
let type: FollowingNewsType
let visibleFromAtUtc: String
let creatorRanking: FollowingCreatorRankingNewsResponse?
let audioContent: FollowingContentNewsResponse?
let photoContent: FollowingContentNewsResponse?
let contentRanking: FollowingContentRankingNewsResponse?
let communityPost: FollowingCommunityPostNewsResponse?
var id: String { newsId }
}
struct FollowingCreatorRankingNewsResponse: Decodable {
let rank: Int
let creatorId: Int
let nickname: String
let profileImageUrl: String
}
struct FollowingContentNewsResponse: Decodable {
let contentId: Int
let contentImageUrl: String?
let title: String
let creatorProfileImageUrl: String
let creatorNickname: String
}
struct FollowingContentRankingNewsResponse: Decodable {
let rank: Int
let contentId: Int
let contentImageUrl: String?
let title: String
}
struct FollowingCommunityPostNewsResponse: Decodable {
let postId: Int
let creatorProfileImage: String
let creatorNickname: String
let imageUrl: String?
let content: String
let createdAt: String
let likeCount: Int
let commentCount: Int
}
enum FollowingNewsType: Decodable, Hashable {
case creatorRanking
case contentRanking
case communityPost
case audioContent
case photoContent
case unknown(String)
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let code = try container.decode(String.self)
switch code {
case "CREATOR_RANKING":
self = .creatorRanking
case "CONTENT_RANKING":
self = .contentRanking
case "COMMUNITY_POST":
self = .communityPost
case "AUDIO_CONTENT":
self = .audioContent
case "PHOTO_CONTENT":
self = .photoContent
default:
self = .unknown(code)
}
}
}

View File

@@ -0,0 +1,38 @@
import Foundation
import Moya
enum MainHomeFollowingApi {
case getFollowing
}
extension MainHomeFollowingApi: TargetType {
var baseURL: URL {
return URL(string: BASE_URL)!
}
var path: String {
switch self {
case .getFollowing:
return "/api/v2/home/following"
}
}
var method: Moya.Method {
switch self {
case .getFollowing:
return .get
}
}
var task: Moya.Task {
switch self {
case .getFollowing:
return .requestPlain
}
}
var headers: [String: String]? {
return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"]
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
import CombineMoya
import Combine
import Moya
final class MainHomeFollowingRepository {
private let api = MoyaProvider<MainHomeFollowingApi>()
func getFollowing() -> AnyPublisher<Response, MoyaError> {
return api.requestPublisher(.getFollowing)
}
}

View File

@@ -11,6 +11,7 @@ struct MainHomeView: View {
let onTapCommunity: (Int) -> Void let onTapCommunity: (Int) -> Void
let onTapBanner: (RecommendationBannerResponse) -> Void let onTapBanner: (RecommendationBannerResponse) -> Void
let onTapFollowAll: (@escaping () -> Void) -> Void let onTapFollowAll: (@escaping () -> Void) -> Void
let onSelectFollowingTab: () -> Bool
@State private var selectedTab: MainHomeTab = .recommendation @State private var selectedTab: MainHomeTab = .recommendation
@@ -31,7 +32,7 @@ struct MainHomeView: View {
TextTabBar( TextTabBar(
items: MainHomeTab.allCases, items: MainHomeTab.allCases,
selectedItem: $selectedTab, selectedItem: selectedTabBinding,
title: { $0.title } title: { $0.title }
) )
@@ -41,6 +42,19 @@ struct MainHomeView: View {
.background(Color.black.ignoresSafeArea()) .background(Color.black.ignoresSafeArea())
} }
private var selectedTabBinding: Binding<MainHomeTab> {
Binding(
get: { selectedTab },
set: { newValue in
if newValue == .following, onSelectFollowingTab() == false {
return
}
selectedTab = newValue
}
)
}
@ViewBuilder @ViewBuilder
private var tabContent: some View { private var tabContent: some View {
switch selectedTab { switch selectedTab {
@@ -91,7 +105,8 @@ struct MainHomeView_Previews: PreviewProvider {
onTapCharacter: { _ in }, onTapCharacter: { _ in },
onTapCommunity: { _ in }, onTapCommunity: { _ in },
onTapBanner: { _ in }, onTapBanner: { _ in },
onTapFollowAll: { action in action() } onTapFollowAll: { action in action() },
onSelectFollowingTab: { true }
) )
} }
} }

View File

@@ -163,7 +163,8 @@ struct MainView: View {
onTapCharacter: handleRecommendationCharacterTap, onTapCharacter: handleRecommendationCharacterTap,
onTapCommunity: handleRecommendationCommunityTap, onTapCommunity: handleRecommendationCommunityTap,
onTapBanner: handleRecommendationBannerTap, onTapBanner: handleRecommendationBannerTap,
onTapFollowAll: handleRecommendationFollowAllTap onTapFollowAll: handleRecommendationFollowAllTap,
onSelectFollowingTab: handleFollowingTabSelection
) )
case .content: case .content:
MainPlaceholderTabView(title: MainTab.content.title) MainPlaceholderTabView(title: MainTab.content.title)
@@ -484,6 +485,15 @@ struct MainView: View {
performRecommendationDetailAction(action: action) performRecommendationDetailAction(action: action)
} }
private func handleFollowingTabSelection() -> Bool {
let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
AppState.shared.setAppStep(step: .login)
return false
}
return true
}
private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) { private func handleRecommendationBannerTap(_ item: RecommendationBannerResponse) {
if let eventItem = item.eventItem { if let eventItem = item.eventItem {

View File

@@ -73,7 +73,7 @@
### Phase 1: 기준점 점검과 탭 guard 설계 ### Phase 1: 기준점 점검과 탭 guard 설계
- [ ] **Task 1.1: PRD와 기존 팔로잉 placeholder 확인** - [x] **Task 1.1: PRD와 기존 팔로잉 placeholder 확인**
- 대상 파일: - 대상 파일:
- 확인: `docs/20260630_메인_홈_팔로잉_탭/prd.md` - 확인: `docs/20260630_메인_홈_팔로잉_탭/prd.md`
- 확인: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift` - 확인: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift`
@@ -86,7 +86,7 @@
- 실행 명령: `sed -n '1,120p' SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift && sed -n '1,120p' SodaLive/Sources/V2/Main/Home/MainHomeView.swift` - 실행 명령: `sed -n '1,120p' SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingView.swift && sed -n '1,120p' SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 기대 결과: 팔로잉 탭은 placeholder이고, `MainHomeView`는 탭 shell 역할만 담당한다. - 기대 결과: 팔로잉 탭은 placeholder이고, `MainHomeView`는 탭 shell 역할만 담당한다.
- [ ] **Task 1.2: 팔로잉 탭 선택 로그인 guard 연결** - [x] **Task 1.2: 팔로잉 탭 선택 로그인 guard 연결**
- 대상 파일: - 대상 파일:
- 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`
- 수정: `SodaLive/Sources/V2/Main/MainView.swift` - 수정: `SodaLive/Sources/V2/Main/MainView.swift`
@@ -105,7 +105,7 @@
### Phase 2: API, Repository, Response model, ViewModel ### Phase 2: API, Repository, Response model, ViewModel
- [ ] **Task 2.1: 팔로잉 응답 모델 생성** - [x] **Task 2.1: 팔로잉 응답 모델 생성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift` - 생성: `SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift`
- 작업 내용: - 작업 내용:
@@ -117,7 +117,7 @@
- 실행 명령: `rg "struct HomeFollowingTabResponse|struct ChatRoomListItemResponse|enum CreatorActivityType|struct FollowingNewsResponse|struct FollowingCreatorRankingNewsResponse|struct FollowingContentNewsResponse|struct FollowingContentRankingNewsResponse|struct FollowingCommunityPostNewsResponse|enum FollowingNewsType|unknown\\(" SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift` - 실행 명령: `rg "struct HomeFollowingTabResponse|struct ChatRoomListItemResponse|enum CreatorActivityType|struct FollowingNewsResponse|struct FollowingCreatorRankingNewsResponse|struct FollowingContentNewsResponse|struct FollowingContentRankingNewsResponse|struct FollowingCommunityPostNewsResponse|enum FollowingNewsType|unknown\\(" SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift`
- 기대 결과: PRD의 응답 모델과 unknown enum 처리가 확인된다. - 기대 결과: PRD의 응답 모델과 unknown enum 처리가 확인된다.
- [ ] **Task 2.2: 팔로잉 API endpoint 생성** - [x] **Task 2.2: 팔로잉 API endpoint 생성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift` - 생성: `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift` - 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/Repository/MainHomeRecommendationApi.swift`
@@ -130,7 +130,7 @@
- 실행 명령: `rg "enum MainHomeFollowingApi|getFollowing|/api/v2/home/following|Authorization|requestPlain|TargetType" SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift` - 실행 명령: `rg "enum MainHomeFollowingApi|getFollowing|/api/v2/home/following|Authorization|requestPlain|TargetType" SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift`
- 기대 결과: 팔로잉 전용 API endpoint와 인증 헤더가 확인된다. - 기대 결과: 팔로잉 전용 API endpoint와 인증 헤더가 확인된다.
- [ ] **Task 2.3: 팔로잉 Repository 생성** - [x] **Task 2.3: 팔로잉 Repository 생성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift` - 생성: `SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift` - 확인: `SodaLive/Sources/V2/Main/Home/Ranking/Repository/MainHomeRankingRepository.swift`
@@ -141,7 +141,7 @@
- 실행 명령: `rg "final class MainHomeFollowingRepository|MoyaProvider<MainHomeFollowingApi>|getFollowing\\(\\)|AnyPublisher<Response, MoyaError>" SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift` - 실행 명령: `rg "final class MainHomeFollowingRepository|MoyaProvider<MainHomeFollowingApi>|getFollowing\\(\\)|AnyPublisher<Response, MoyaError>" SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift`
- 기대 결과: 기존 V2 홈 Repository와 같은 패턴이 확인된다. - 기대 결과: 기존 V2 홈 Repository와 같은 패턴이 확인된다.
- [ ] **Task 2.4: 팔로잉 ViewModel 생성** - [x] **Task 2.4: 팔로잉 ViewModel 생성**
- 대상 파일: - 대상 파일:
- 생성: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift` - 생성: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift`
- 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift` - 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift`
@@ -159,7 +159,7 @@
- 실행 명령: `rg "final class MainHomeFollowingViewModel|fetchFollowing|ApiResponse<HomeFollowingTabResponse>|isLoginRequired|emptyStateMessage|loadFailedMessage|hasLoaded|ERROR_LOG" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift` - 실행 명령: `rg "final class MainHomeFollowingViewModel|fetchFollowing|ApiResponse<HomeFollowingTabResponse>|isLoginRequired|emptyStateMessage|loadFailedMessage|hasLoaded|ERROR_LOG" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift`
- 기대 결과: API 디코딩, 로그인 필요 상태, empty/API 실패 문구 처리가 확인된다. - 기대 결과: API 디코딩, 로그인 필요 상태, empty/API 실패 문구 처리가 확인된다.
- [ ] **Task 2.5: 팔로잉 I18n 문구 추가** - [x] **Task 2.5: 팔로잉 I18n 문구 추가**
- 대상 파일: - 대상 파일:
- 수정: `SodaLive/Sources/I18n/I18n.swift` - 수정: `SodaLive/Sources/I18n/I18n.swift`
- 작업 내용: - 작업 내용:
@@ -467,3 +467,8 @@
- 2026-06-30: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`의 탭 전환이 `TextTabBar` binding 기반임을 확인해 custom binding setter 방식으로 로그인 guard 계획을 세웠다. - 2026-06-30: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift`의 탭 전환이 `TextTabBar` binding 기반임을 확인해 custom binding setter 방식으로 로그인 guard 계획을 세웠다.
- 2026-06-30: `SodaLive/Sources/ContentView.swift`에서 `.followingList -> FollowCreatorView()`, `.chatRoom(id:) -> ChatRoomView(roomId:)` 라우팅을 확인했다. - 2026-06-30: `SodaLive/Sources/ContentView.swift`에서 `.followingList -> FollowCreatorView()`, `.chatRoom(id:) -> ChatRoomView(roomId:)` 라우팅을 확인했다.
- 2026-06-30: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift``AppState`의 pending community post 구조가 `creatorId`를 요구함을 확인해 `COMMUNITY_POST` tap 라우팅 제약을 계획에 명시했다. - 2026-06-30: `SodaLive/Sources/Explorer/Profile/CreatorCommunity/All/CreatorCommunityAllView.swift``AppState`의 pending community post 구조가 `creatorId`를 요구함을 확인해 `COMMUNITY_POST` tap 라우팅 제약을 계획에 명시했다.
- 2026-06-30: Phase 1-2 구현을 진행해 `MainHomeView`의 팔로잉 탭 custom `Binding` guard, `MainView.handleFollowingTabSelection()`, 팔로잉 응답 모델/API/Repository/ViewModel, `I18n.HomeFollowing` 문구를 추가했다.
- 2026-06-30: `rg "onSelectFollowingTab|Binding\(|selectedTab = \.following|setAppStep\(step: \.login\)|TextTabBar" SodaLive/Sources/V2/Main/Home/MainHomeView.swift SodaLive/Sources/V2/Main/MainView.swift`로 팔로잉 탭 guard 연결을 확인했다.
- 2026-06-30: `rg "struct HomeFollowingTabResponse|struct ChatRoomListItemResponse|enum CreatorActivityType|struct FollowingNewsResponse|struct FollowingCreatorRankingNewsResponse|struct FollowingContentNewsResponse|struct FollowingContentRankingNewsResponse|struct FollowingCommunityPostNewsResponse|enum FollowingNewsType|unknown\(" SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift`로 Phase 2 응답 모델과 unknown enum 처리를 확인했다.
- 2026-06-30: `rg "enum MainHomeFollowingApi|getFollowing|/api/v2/home/following|Authorization|requestPlain|TargetType|final class MainHomeFollowingRepository|MoyaProvider<MainHomeFollowingApi>|AnyPublisher<Response, MoyaError>" SodaLive/Sources/V2/Main/Home/Following/Repository`로 API/Repository 패턴을 확인했다.
- 2026-06-30: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`를 실행해 빌드 성공을 확인했다. 기존 프로젝트의 duplicate build file/run script warning은 남아 있으나 Phase 1-2 컴파일 오류는 없었다.