diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 2af70bb6..32b7caf5 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -246,6 +246,18 @@ 401B3F072FEEF10000A5BBA3 /* MainHomeRankingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */; }; 401B3F082FEEF10000A5BBA3 /* 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 */; }; 40235DEE2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.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 = ""; }; 401B3F0C2FEEF10000A5BBA3 /* MainHomeRankingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingRepository.swift; sourceTree = ""; }; 401B3F0D2FEEF10000A5BBA3 /* MainHomeRankingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingViewModel.swift; sourceTree = ""; }; + 401C10012FEF300000A5BBA3 /* HomeFollowingTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeFollowingTabResponse.swift; sourceTree = ""; }; + 401C10022FEF300000A5BBA3 /* MainHomeFollowingApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingApi.swift; sourceTree = ""; }; + 401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingRepository.swift; sourceTree = ""; }; + 401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingViewModel.swift; sourceTree = ""; }; 40235DEC2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RecentContent+CoreDataClass.swift"; sourceTree = ""; }; 40235DEF2E37983D00B09A87 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; 40235DF22E37987300B09A87 /* RecentContentService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecentContentService.swift; sourceTree = ""; }; @@ -2779,6 +2795,9 @@ 401A3FB92FEEAFA000A5BBA3 /* Following */ = { isa = PBXGroup; children = ( + 401C10052FEF300000A5BBA3 /* Models */, + 401C10062FEF300000A5BBA3 /* Repository */, + 401C10042FEF300000A5BBA3 /* MainHomeFollowingViewModel.swift */, 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */, ); path = Following; @@ -2817,6 +2836,23 @@ path = Repository; sourceTree = ""; }; + 401C10052FEF300000A5BBA3 /* Models */ = { + isa = PBXGroup; + children = ( + 401C10012FEF300000A5BBA3 /* HomeFollowingTabResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + 401C10062FEF300000A5BBA3 /* Repository */ = { + isa = PBXGroup; + children = ( + 401C10022FEF300000A5BBA3 /* MainHomeFollowingApi.swift */, + 401C10032FEF300000A5BBA3 /* MainHomeFollowingRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; 40235DEA2E3797B000B09A87 /* Recent */ = { isa = PBXGroup; children = ( @@ -5427,6 +5463,10 @@ 401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.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 */, 40975CA32B572298004E2F1B /* LiveRoomOverlayStrokeTextButton.swift in Sources */, 40235DED2E3797EB00B09A87 /* RecentContent+CoreDataClass.swift in Sources */, @@ -6187,6 +6227,10 @@ 401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.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 */, CA65AD0D677B4A8E9CAC0E15 /* MainTab.swift in Sources */, A0643C829BE7377DC09112CB /* MainTabBarButton.swift in Sources */, @@ -6977,6 +7021,10 @@ 401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.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 */, 33BA3AB731849284E62AE04B /* MainTab.swift in Sources */, B11681AC02BF51C6E2FA6FF3 /* MainTabBarButton.swift in Sources */, diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index fa31440a..83cabe1a 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -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 { static var emptyStateMessage: String { pick( diff --git a/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift b/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift new file mode 100644 index 00000000..f202bcc0 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift @@ -0,0 +1,73 @@ +import Foundation +import Combine + +final class MainHomeFollowingViewModel: ObservableObject { + private let repository = MainHomeFollowingRepository() + private var subscription = Set() + + @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.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 + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift b/SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift new file mode 100644 index 00000000..aa4c9212 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/Models/HomeFollowingTabResponse.swift @@ -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) + } + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift b/SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift new file mode 100644 index 00000000..7d921e89 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingApi.swift @@ -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))"] + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift b/SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift new file mode 100644 index 00000000..1a06f458 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift @@ -0,0 +1,12 @@ +import Foundation +import CombineMoya +import Combine +import Moya + +final class MainHomeFollowingRepository { + private let api = MoyaProvider() + + func getFollowing() -> AnyPublisher { + return api.requestPublisher(.getFollowing) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift index 5f1e6785..663c860a 100644 --- a/SodaLive/Sources/V2/Main/Home/MainHomeView.swift +++ b/SodaLive/Sources/V2/Main/Home/MainHomeView.swift @@ -11,6 +11,7 @@ struct MainHomeView: View { let onTapCommunity: (Int) -> Void let onTapBanner: (RecommendationBannerResponse) -> Void let onTapFollowAll: (@escaping () -> Void) -> Void + let onSelectFollowingTab: () -> Bool @State private var selectedTab: MainHomeTab = .recommendation @@ -31,7 +32,7 @@ struct MainHomeView: View { TextTabBar( items: MainHomeTab.allCases, - selectedItem: $selectedTab, + selectedItem: selectedTabBinding, title: { $0.title } ) @@ -41,6 +42,19 @@ struct MainHomeView: View { .background(Color.black.ignoresSafeArea()) } + private var selectedTabBinding: Binding { + Binding( + get: { selectedTab }, + set: { newValue in + if newValue == .following, onSelectFollowingTab() == false { + return + } + + selectedTab = newValue + } + ) + } + @ViewBuilder private var tabContent: some View { switch selectedTab { @@ -91,7 +105,8 @@ struct MainHomeView_Previews: PreviewProvider { onTapCharacter: { _ in }, onTapCommunity: { _ in }, onTapBanner: { _ in }, - onTapFollowAll: { action in action() } + onTapFollowAll: { action in action() }, + onSelectFollowingTab: { true } ) } } diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index fbb60911..9bd0de1c 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -163,7 +163,8 @@ struct MainView: View { onTapCharacter: handleRecommendationCharacterTap, onTapCommunity: handleRecommendationCommunityTap, onTapBanner: handleRecommendationBannerTap, - onTapFollowAll: handleRecommendationFollowAllTap + onTapFollowAll: handleRecommendationFollowAllTap, + onSelectFollowingTab: handleFollowingTabSelection ) case .content: MainPlaceholderTabView(title: MainTab.content.title) @@ -484,6 +485,15 @@ struct MainView: View { 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) { if let eventItem = item.eventItem { diff --git a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md index 94d7dc6d..ffd7ed9c 100644 --- a/docs/20260630_메인_홈_팔로잉_탭/plan-task.md +++ b/docs/20260630_메인_홈_팔로잉_탭/plan-task.md @@ -73,7 +73,7 @@ ### Phase 1: 기준점 점검과 탭 guard 설계 -- [ ] **Task 1.1: PRD와 기존 팔로잉 placeholder 확인** +- [x] **Task 1.1: PRD와 기존 팔로잉 placeholder 확인** - 대상 파일: - 확인: `docs/20260630_메인_홈_팔로잉_탭/prd.md` - 확인: `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` - 기대 결과: 팔로잉 탭은 placeholder이고, `MainHomeView`는 탭 shell 역할만 담당한다. -- [ ] **Task 1.2: 팔로잉 탭 선택 로그인 guard 연결** +- [x] **Task 1.2: 팔로잉 탭 선택 로그인 guard 연결** - 대상 파일: - 수정: `SodaLive/Sources/V2/Main/Home/MainHomeView.swift` - 수정: `SodaLive/Sources/V2/Main/MainView.swift` @@ -105,7 +105,7 @@ ### Phase 2: API, Repository, Response model, ViewModel -- [ ] **Task 2.1: 팔로잉 응답 모델 생성** +- [x] **Task 2.1: 팔로잉 응답 모델 생성** - 대상 파일: - 생성: `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` - 기대 결과: 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/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` - 기대 결과: 팔로잉 전용 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/Ranking/Repository/MainHomeRankingRepository.swift` @@ -141,7 +141,7 @@ - 실행 명령: `rg "final class MainHomeFollowingRepository|MoyaProvider|getFollowing\\(\\)|AnyPublisher" SodaLive/Sources/V2/Main/Home/Following/Repository/MainHomeFollowingRepository.swift` - 기대 결과: 기존 V2 홈 Repository와 같은 패턴이 확인된다. -- [ ] **Task 2.4: 팔로잉 ViewModel 생성** +- [x] **Task 2.4: 팔로잉 ViewModel 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift` - 확인: `SodaLive/Sources/V2/Main/Home/Recommendation/MainHomeRecommendationViewModel.swift` @@ -159,7 +159,7 @@ - 실행 명령: `rg "final class MainHomeFollowingViewModel|fetchFollowing|ApiResponse|isLoginRequired|emptyStateMessage|loadFailedMessage|hasLoaded|ERROR_LOG" SodaLive/Sources/V2/Main/Home/Following/MainHomeFollowingViewModel.swift` - 기대 결과: API 디코딩, 로그인 필요 상태, empty/API 실패 문구 처리가 확인된다. -- [ ] **Task 2.5: 팔로잉 I18n 문구 추가** +- [x] **Task 2.5: 팔로잉 I18n 문구 추가** - 대상 파일: - 수정: `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/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: 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|AnyPublisher" 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 컴파일 오류는 없었다.